feat(export): server-rendered artifacts behind one signed download URL
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The finished-game export now works identically on every platform: the client mints a signed relative URL (game.export_url) carrying its date locale, IANA time zone and localized non-play labels, resolves it against its own origin and hands it to the platform's native download — Telegram downloadFile, VKWebAppDownloadFile, or a plain browser anchor (also the desktop VK iframe). Both artifacts ride the route: the .gcg text (no more clipboard mode, except the legacy pre-8.0 Telegram fallback) and the PNG of the final position. The PNG is rasterized by a new internal 'renderer' sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the web project unit-tests, bundled at image build time — one renderer, no drift; the browser no longer draws or delivers bytes itself. The backend rebuilds the render payload from the journal + engine.AlphabetTable, verifies the HMAC (10-minute TTL, BACKEND_EXPORT_SIGN_KEY, constant-time, uniform 404s) on its public group, and streams the artifact as a named attachment; the gateway forwards /dl/* behind the per-IP public rate limiter (caddy @gateway matcher extended — the landing catch-all trap). Deploy: renderer service (compose + prod overlay + roll before backend + prod push list), EXPORT_SIGN_KEY env (TEST_/PROD_ secrets), CI runs the sidecar smoke in the ui job. Docs: ARCHITECTURE export-delivery section, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README.
This commit is contained in:
@@ -33,6 +33,7 @@ import (
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/pushgrpc"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -232,6 +233,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// block and the banner admin console section.
|
||||
adsSvc := ads.NewService(ads.NewStore(db))
|
||||
|
||||
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||
var renderer *render.Client
|
||||
if cfg.RendererURL != "" {
|
||||
renderer = render.New(cfg.RendererURL)
|
||||
}
|
||||
|
||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||
Logger: logger,
|
||||
DB: db,
|
||||
@@ -253,6 +261,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
BanView: banView,
|
||||
Ads: adsSvc,
|
||||
Notifier: hub,
|
||||
ExportSignKey: cfg.ExportSignKey,
|
||||
Renderer: renderer,
|
||||
})
|
||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ type Config struct {
|
||||
// GuestRetention is the account age past which an unused guest (no game seat)
|
||||
// is eligible for deletion by the reaper.
|
||||
GuestRetention time.Duration
|
||||
// ExportSignKey signs the finished-game export download URLs. Empty leaves
|
||||
// the export-URL endpoints disabled (503 on mint, 404 on download).
|
||||
ExportSignKey string
|
||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||
RendererURL string
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -151,6 +157,8 @@ func Load() (Config, error) {
|
||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||
GuestReapInterval: guestReapInterval,
|
||||
GuestRetention: guestRetention,
|
||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
|
||||
@@ -1363,30 +1363,53 @@ func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDr
|
||||
return svc.store.SetupDraws(ctx, gameID)
|
||||
}
|
||||
|
||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
|
||||
// is allowed only on a finished game: exporting an in-progress game would leak the
|
||||
// full move journal mid-play, so an active game yields ErrGameActive.
|
||||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
// ExportView returns a finished game with its journal and per-seat display names —
|
||||
// the material every export artifact (the GCG text, the PNG render payload) is built
|
||||
// from. It is allowed only on a finished game: exporting an in-progress game would
|
||||
// leak the full move journal mid-play, so an active game yields ErrGameActive. In an
|
||||
// honest-AI game the robot seat is labelled "AI", not its pool name.
|
||||
func (svc *Service) ExportView(ctx context.Context, gameID uuid.UUID) (Game, []HistoryMove, []string, error) {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return Game{}, nil, nil, err
|
||||
}
|
||||
if g.Status != StatusFinished {
|
||||
return "", ErrGameActive
|
||||
return Game{}, nil, nil, ErrGameActive
|
||||
}
|
||||
moves, err := svc.store.GetJournal(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return Game{}, nil, nil, err
|
||||
}
|
||||
names := svc.seatNames(ctx, g)
|
||||
if g.VsAI {
|
||||
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
|
||||
for _, s := range g.Seats {
|
||||
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
|
||||
names[s.Seat] = aiPlayerName
|
||||
}
|
||||
}
|
||||
}
|
||||
return g, moves, names, nil
|
||||
}
|
||||
|
||||
// EnsureExportable reports whether a game may be exported (it exists and is
|
||||
// finished) without loading the journal — the export-URL mint check.
|
||||
func (svc *Service) EnsureExportable(ctx context.Context, gameID uuid.UUID) error {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Status != StatusFinished {
|
||||
return ErrGameActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
|
||||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
g, moves, names, err := svc.ExportView(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return writeGCG(g, names, moves), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package render is the backend's client for the internal image-render sidecar: it POSTs
|
||||
// a finished game's export payload and receives the rasterized PNG. The sidecar runs the
|
||||
// same drawing code the web client unit-tests (ui/src/lib/gameimage.ts on skia-canvas);
|
||||
// authentication and the signed public URL live in the server layer — this client only
|
||||
// carries bytes.
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxPNG caps a render response; a scale-2 export of the largest plausible game is well
|
||||
// under 2 MiB, so anything bigger is a sidecar malfunction, not a valid image.
|
||||
const maxPNG = 8 << 20
|
||||
|
||||
// Client calls the render sidecar over the internal network.
|
||||
type Client struct {
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a client for the sidecar at baseURL (e.g. http://renderer:8090). A render
|
||||
// of a full game takes well under a second; the timeout leaves room for a cold start.
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{base: baseURL, http: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
// Render posts payload (the JSON request shape the sidecar consumes) and returns the PNG.
|
||||
func (c *Client) Render(ctx context.Context, payload any) ([]byte, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render: marshal payload: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/render", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("render: sidecar status %d", resp.StatusCode)
|
||||
}
|
||||
png, err := io.ReadAll(io.LimitReader(resp.Body, maxPNG+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render: read response: %w", err)
|
||||
}
|
||||
if len(png) > maxPNG {
|
||||
return nil, fmt.Errorf("render: response exceeds %d bytes", maxPNG)
|
||||
}
|
||||
return png, nil
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// Finished-game export downloads: a signed, short-lived, relative URL minted for a
|
||||
// participant and later fetched with no credentials at all — the platforms' native
|
||||
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain browser
|
||||
// anchor) strip cookies and headers, so the HMAC in the URL is the whole grant.
|
||||
// The client resolves the relative path against its own origin; the edge routes
|
||||
// /dl/* to the gateway, which forwards it here (/api/v1/public/dl/...).
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// exportURLTTL is how long a minted download URL stays valid. Long enough for the
|
||||
// platform's download dialog and a retry, short enough that a leaked link dies fast.
|
||||
const exportURLTTL = 10 * time.Minute
|
||||
|
||||
// Query/label size caps: the presentation params ride the signed URL, so they are
|
||||
// bounded defensively (they only ever hold a BCP-47 tag and four short UI words).
|
||||
const (
|
||||
maxDateLocaleLen = 35
|
||||
maxLabelsLen = 120
|
||||
maxTimeZoneLen = 50
|
||||
)
|
||||
|
||||
// exportActionOrder fixes the position of each non-play move label in the `t` CSV.
|
||||
var exportActionOrder = [4]string{"pass", "exchange", "resign", "timeout"}
|
||||
|
||||
// signExport computes the URL signature over the canonical parameter string. The
|
||||
// canonical form is independent of the public path prefix, so the gateway may mount
|
||||
// the route anywhere.
|
||||
func (s *Server) signExport(gameID, kind, exp, dateLocale, timeZone, labels string) string {
|
||||
mac := hmac.New(sha256.New, s.exportKey)
|
||||
fmt.Fprintf(mac, "%s|%s|%s|%s|%s|%s", gameID, kind, exp, dateLocale, timeZone, labels)
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// exportURLDTO is the minted link: a relative signed path plus the download filename.
|
||||
type exportURLDTO struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// handleExportURL mints the signed download path for a finished game's artifact.
|
||||
// GET /api/v1/user/games/:id/export-url?kind=png|gcg&dl=<date-locale>&tz=<IANA zone>&t=<labels CSV>
|
||||
func (s *Server) handleExportURL(c *gin.Context) {
|
||||
if len(s.exportKey) == 0 {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable,
|
||||
errorResponse{Error: errorBody{Code: "export_unavailable", Message: "export signing is not configured"}})
|
||||
return
|
||||
}
|
||||
_, gameID, ok := s.userGame(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kind := c.Query("kind")
|
||||
if kind != "png" && kind != "gcg" {
|
||||
abortBadRequest(c, "invalid kind")
|
||||
return
|
||||
}
|
||||
dateLocale := c.Query("dl")
|
||||
timeZone := c.Query("tz")
|
||||
labels := c.Query("t")
|
||||
if len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
|
||||
abortBadRequest(c, "presentation params too long")
|
||||
return
|
||||
}
|
||||
if err := s.games.EnsureExportable(c.Request.Context(), gameID); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
exp := strconv.FormatInt(time.Now().Add(exportURLTTL).Unix(), 10)
|
||||
sig := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
|
||||
q := url.Values{"e": {exp}, "s": {sig}}
|
||||
if dateLocale != "" {
|
||||
q.Set("l", dateLocale)
|
||||
}
|
||||
if timeZone != "" {
|
||||
q.Set("z", timeZone)
|
||||
}
|
||||
if labels != "" {
|
||||
q.Set("t", labels)
|
||||
}
|
||||
c.JSON(http.StatusOK, exportURLDTO{
|
||||
Path: "/dl/" + gameID.String() + "/" + kind + "?" + q.Encode(),
|
||||
Filename: exportFilename(gameID, kind),
|
||||
})
|
||||
}
|
||||
|
||||
func exportFilename(gameID uuid.UUID, kind string) string {
|
||||
return "game-" + gameID.String()[:8] + "." + kind
|
||||
}
|
||||
|
||||
// handleExportDownload serves a minted artifact. It is a public route: the signature
|
||||
// and expiry are the only authorization (see the package comment). Every failure is
|
||||
// a plain 404 so the endpoint is not a validity oracle.
|
||||
// GET /api/v1/public/dl/:id/:kind?e=&l=&z=&t=&s=
|
||||
func (s *Server) handleExportDownload(c *gin.Context) {
|
||||
if len(s.exportKey) == 0 {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
gameID, err := uuid.Parse(c.Param("id"))
|
||||
kind := c.Param("kind")
|
||||
exp := c.Query("e")
|
||||
dateLocale := c.Query("l")
|
||||
timeZone := c.Query("z")
|
||||
labels := c.Query("t")
|
||||
sig := c.Query("s")
|
||||
if err != nil || (kind != "png" && kind != "gcg") ||
|
||||
len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
want := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
|
||||
if subtleNeq(sig, want) {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if unix, err := strconv.ParseInt(exp, 10, 64); err != nil || time.Now().Unix() > unix {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
filename := exportFilename(gameID, kind)
|
||||
if kind == "gcg" {
|
||||
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
serveAttachment(c, filename, "text/plain; charset=utf-8", []byte(gcg))
|
||||
return
|
||||
}
|
||||
|
||||
if s.renderer == nil {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
g, moves, names, err := s.games.ExportView(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
payload, err := renderPayload(g, moves, names, dateLocale, timeZone, labels, c.GetHeader("X-Public-Host"))
|
||||
if err != nil {
|
||||
s.log.Warn("export render payload", zap.Error(err))
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
png, err := s.renderer.Render(c.Request.Context(), payload)
|
||||
if err != nil {
|
||||
s.log.Warn("export render", zap.Error(err))
|
||||
c.String(http.StatusServiceUnavailable, "render unavailable")
|
||||
return
|
||||
}
|
||||
serveAttachment(c, filename, "image/png", png)
|
||||
}
|
||||
|
||||
// subtleNeq compares two signature strings in constant time.
|
||||
func subtleNeq(got, want string) bool {
|
||||
return !hmac.Equal([]byte(got), []byte(want))
|
||||
}
|
||||
|
||||
// serveAttachment writes bytes as a named file download.
|
||||
func serveAttachment(c *gin.Context, filename, contentType string, data []byte) {
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(filename)+`"`)
|
||||
c.Header("Cache-Control", "private, max-age=0")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
// --- the render-sidecar request payload (the ui-model shapes drawGameImage consumes) ---
|
||||
|
||||
type renderSeatJSON struct {
|
||||
Seat int `json:"seat"`
|
||||
AccountID string `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Score int `json:"score"`
|
||||
HintsUsed int `json:"hintsUsed"`
|
||||
IsWinner bool `json:"isWinner"`
|
||||
}
|
||||
|
||||
type renderGameJSON struct {
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant"`
|
||||
Status string `json:"status"`
|
||||
Players int `json:"players"`
|
||||
LastActivityUnix int64 `json:"lastActivityUnix"`
|
||||
Seats []renderSeatJSON `json:"seats"`
|
||||
}
|
||||
|
||||
type renderTileJSON struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
Letter string `json:"letter"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
type renderMoveJSON struct {
|
||||
Player int `json:"player"`
|
||||
Action string `json:"action"`
|
||||
Dir string `json:"dir"`
|
||||
MainRow int `json:"mainRow"`
|
||||
MainCol int `json:"mainCol"`
|
||||
Tiles []renderTileJSON `json:"tiles"`
|
||||
Words []string `json:"words"`
|
||||
Count int `json:"count"`
|
||||
Score int `json:"score"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type renderAlphaJSON struct {
|
||||
Index int `json:"index"`
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
type renderRequestJSON struct {
|
||||
Game renderGameJSON `json:"game"`
|
||||
Moves []renderMoveJSON `json:"moves"`
|
||||
Alphabet []renderAlphaJSON `json:"alphabet"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
DateLocale string `json:"dateLocale"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
// renderPayload assembles the sidecar request from the export view. Letters are
|
||||
// upper-cased for display exactly as the client codec does; the localized non-play
|
||||
// labels arrive as a positional CSV (see exportActionOrder) minted into the URL.
|
||||
func renderPayload(g game.Game, moves []game.HistoryMove, names []string, dateLocale, timeZone, labelsCSV, host string) (renderRequestJSON, error) {
|
||||
alpha, err := engine.AlphabetTable(g.Variant)
|
||||
if err != nil {
|
||||
return renderRequestJSON{}, fmt.Errorf("alphabet for %s: %w", g.Variant, err)
|
||||
}
|
||||
req := renderRequestJSON{
|
||||
Game: renderGameJSON{
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
},
|
||||
Labels: map[string]string{},
|
||||
DateLocale: dateLocale,
|
||||
TimeZone: timeZone,
|
||||
Hostname: host,
|
||||
}
|
||||
finished := g.UpdatedAt
|
||||
if g.FinishedAt != nil {
|
||||
finished = *g.FinishedAt
|
||||
}
|
||||
req.Game.LastActivityUnix = finished.Unix()
|
||||
for _, st := range g.Seats {
|
||||
name := st.DisplayName
|
||||
if st.Seat < len(names) {
|
||||
name = names[st.Seat]
|
||||
}
|
||||
req.Game.Seats = append(req.Game.Seats, renderSeatJSON{
|
||||
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: name,
|
||||
Score: st.Score, HintsUsed: st.HintsUsed, IsWinner: st.IsWinner,
|
||||
})
|
||||
}
|
||||
for _, m := range moves {
|
||||
mv := renderMoveJSON{
|
||||
Player: m.Seat, Action: m.Action, Dir: m.Dir,
|
||||
MainRow: m.MainRow, MainCol: m.MainCol,
|
||||
Words: make([]string, 0, len(m.Words)),
|
||||
Count: len(m.Exchanged), Score: m.Score, Total: m.RunningTotal,
|
||||
Tiles: make([]renderTileJSON, 0, len(m.Tiles)),
|
||||
}
|
||||
for _, w := range m.Words {
|
||||
mv.Words = append(mv.Words, strings.ToUpper(w))
|
||||
}
|
||||
for _, t := range m.Tiles {
|
||||
mv.Tiles = append(mv.Tiles, renderTileJSON{Row: t.Row, Col: t.Col, Letter: strings.ToUpper(t.Letter), Blank: t.Blank})
|
||||
}
|
||||
req.Moves = append(req.Moves, mv)
|
||||
}
|
||||
for _, a := range alpha {
|
||||
req.Alphabet = append(req.Alphabet, renderAlphaJSON{Index: int(a.Index), Letter: strings.ToUpper(a.Letter), Value: a.Value})
|
||||
}
|
||||
if labelsCSV != "" {
|
||||
parts := strings.Split(labelsCSV, ",")
|
||||
for i, action := range exportActionOrder {
|
||||
if i < len(parts) && parts[i] != "" {
|
||||
req.Labels[action] = parts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// newExportServer is the routing harness with a signing key installed, so the
|
||||
// pre-service branches of the export endpoints are reachable.
|
||||
func newExportServer() *Server {
|
||||
return New(":0", Deps{
|
||||
Sessions: &session.Service{},
|
||||
Accounts: &account.Store{},
|
||||
Games: &game.Service{},
|
||||
ExportSignKey: "test-key",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignExportIsDeterministicAndTamperEvident(t *testing.T) {
|
||||
s := newExportServer()
|
||||
sig := s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен")
|
||||
if sig != s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен") {
|
||||
t.Fatal("same inputs produced different signatures")
|
||||
}
|
||||
for _, tampered := range []string{
|
||||
s.signExport("g2", "png", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||
s.signExport("g1", "gcg", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||
s.signExport("g1", "png", "124", "ru", "Europe/Moscow", "пас,обмен"),
|
||||
s.signExport("g1", "png", "123", "en", "Europe/Moscow", "пас,обмен"),
|
||||
s.signExport("g1", "png", "123", "ru", "UTC", "пас,обмен"),
|
||||
s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас"),
|
||||
} {
|
||||
if tampered == sig {
|
||||
t.Fatal("tampered input produced the same signature")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportURLWithoutKeyIs503(t *testing.T) {
|
||||
s := New(":0", Deps{Sessions: &session.Service{}, Accounts: &account.Store{}, Games: &game.Service{}})
|
||||
rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+uuid.NewString()+"/export-url?kind=png", "",
|
||||
map[string]string{"X-User-ID": uuid.NewString()})
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("mint without key = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportURLRejectsBadParams(t *testing.T) {
|
||||
s := newExportServer()
|
||||
uid := map[string]string{"X-User-ID": uuid.NewString()}
|
||||
gid := uuid.NewString()
|
||||
|
||||
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=exe", "", uid); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad kind = %d, want 400", rec.Code)
|
||||
}
|
||||
long := strings.Repeat("x", maxLabelsLen+1)
|
||||
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=png&t="+long, "", uid); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("oversized labels = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportDownloadRejectsBadSignatures(t *testing.T) {
|
||||
s := newExportServer()
|
||||
gid := uuid.NewString()
|
||||
exp := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)
|
||||
|
||||
// A wrong signature never reaches the domain (404 before any service call).
|
||||
url := "/api/v1/public/dl/" + gid + "/png?e=" + exp + "&s=forged"
|
||||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("forged signature = %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// A valid signature over an EXPIRED timestamp is refused the same way.
|
||||
old := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)
|
||||
url = "/api/v1/public/dl/" + gid + "/png?e=" + old + "&s=" + s.signExport(gid, "png", old, "", "", "")
|
||||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expired link = %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// An unknown kind is refused before signature checks.
|
||||
url = "/api/v1/public/dl/" + gid + "/exe?e=" + exp + "&s=x"
|
||||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("bad kind = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPayloadMapsTheExportView(t *testing.T) {
|
||||
gid := uuid.New()
|
||||
finished := time.Unix(1_782_997_629, 0)
|
||||
g := game.Game{
|
||||
ID: gid,
|
||||
Variant: engine.VariantRussianScrabble,
|
||||
Status: game.StatusFinished,
|
||||
Players: 2,
|
||||
FinishedAt: &finished,
|
||||
Seats: []game.Seat{
|
||||
{Seat: 0, Score: 42, IsWinner: true, DisplayName: "snapshot"},
|
||||
{Seat: 1, Score: 30},
|
||||
},
|
||||
}
|
||||
moves := []game.HistoryMove{
|
||||
{
|
||||
Seat: 0, Action: "play", Dir: "H", MainRow: 7, MainCol: 4,
|
||||
Tiles: []engine.TileRecord{{Row: 7, Col: 4, Letter: "ч", Blank: false}},
|
||||
Words: []string{"чика"}, Score: 9, RunningTotal: 9,
|
||||
},
|
||||
{Seat: 1, Action: "exchange", Exchanged: []string{"а", "б"}, RunningTotal: 0},
|
||||
}
|
||||
names := []string{"Аня", "Боб"}
|
||||
|
||||
p, err := renderPayload(g, moves, names, "ru", "Europe/Moscow", "пас,обмен,сдался,таймаут", "erudit-game.ru")
|
||||
if err != nil {
|
||||
t.Fatalf("renderPayload: %v", err)
|
||||
}
|
||||
if p.Game.LastActivityUnix != finished.Unix() {
|
||||
t.Fatalf("lastActivityUnix = %d, want %d", p.Game.LastActivityUnix, finished.Unix())
|
||||
}
|
||||
if p.Game.Seats[0].DisplayName != "Аня" || !p.Game.Seats[0].IsWinner {
|
||||
t.Fatalf("seat 0 = %+v, want the resolved name and the winner flag", p.Game.Seats[0])
|
||||
}
|
||||
if p.Moves[0].Words[0] != "ЧИКА" || p.Moves[0].Tiles[0].Letter != "Ч" {
|
||||
t.Fatalf("letters not upper-cased: %+v", p.Moves[0])
|
||||
}
|
||||
if p.Moves[1].Count != 2 {
|
||||
t.Fatalf("exchange count = %d, want 2", p.Moves[1].Count)
|
||||
}
|
||||
if p.Labels["pass"] != "пас" || p.Labels["timeout"] != "таймаут" {
|
||||
t.Fatalf("labels not mapped positionally: %v", p.Labels)
|
||||
}
|
||||
if len(p.Alphabet) == 0 || p.Hostname != "erudit-game.ru" || p.TimeZone != "Europe/Moscow" {
|
||||
t.Fatalf("alphabet/hostname/zone missing: %d entries, host %q, tz %q", len(p.Alphabet), p.Hostname, p.TimeZone)
|
||||
}
|
||||
for _, a := range p.Alphabet {
|
||||
if a.Letter != strings.ToUpper(a.Letter) {
|
||||
t.Fatalf("alphabet letter %q not upper-cased", a.Letter)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,12 +87,17 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||
u.GET("/games/:id/history", s.handleHistory)
|
||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||
u.GET("/games/:id/export-url", s.handleExportURL)
|
||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||
u.POST("/games/:id/hide", s.handleHideGame)
|
||||
// Raw dictionary download for the client-side local move preview, keyed by
|
||||
// the game's pinned (variant, version); immutable, so cached hard.
|
||||
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
||||
// The signed finished-game export download — the only public data route:
|
||||
// the URL's HMAC + expiry are the whole grant (export.go), because the
|
||||
// platforms' native download calls carry no credentials.
|
||||
s.public.GET("/dl/:id/:kind", s.handleExportDownload)
|
||||
}
|
||||
if s.feedback != nil {
|
||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
@@ -96,6 +97,12 @@ type Deps struct {
|
||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||
// them (notify.Nop).
|
||||
Notifier notify.Publisher
|
||||
// ExportSignKey signs the finished-game export download URLs (export.go). An
|
||||
// empty key leaves the export-URL endpoints answering 503/404.
|
||||
ExportSignKey string
|
||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||
Renderer *render.Client
|
||||
}
|
||||
|
||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||
@@ -124,6 +131,8 @@ type Server struct {
|
||||
ads *ads.Service
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
renderer *render.Client
|
||||
|
||||
public *gin.RouterGroup
|
||||
user *gin.RouterGroup
|
||||
@@ -173,8 +182,12 @@ func New(addr string, deps Deps) *Server {
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
}
|
||||
if deps.ExportSignKey != "" {
|
||||
s.exportKey = []byte(deps.ExportSignKey)
|
||||
}
|
||||
s.registerProbes(engine)
|
||||
s.registerAPIGroups(engine)
|
||||
s.registerRoutes()
|
||||
|
||||
Reference in New Issue
Block a user