From d5fbaa3034a5c746417b413d3684ab63d2431e0a Mon Sep 17 00:00:00 2001 From: developer Date: Thu, 2 Jul 2026 21:58:07 +0000 Subject: [PATCH] feat(export): server-rendered artifacts behind one signed download URL (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finished-game export (GCG + a new PNG of the final position) is one signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin and delivered by the best affordance each platform has (five on-device review rounds): - TG Android/desktop: native showPopup chooser -> native downloadFile dialog (bridge-only chain, activation-safe). - TG iOS: app-modal chooser -> OS share sheet with the fetched file (a popup callback cannot supply the activation the sheet needs). - VK iOS: VKWebAppDownloadFile for both formats. - VK Android: the PNG opens in VK's native image viewer, the GCG copies to the clipboard (the VK Android downloader hangs on any download, Content-Length/Range notwithstanding). - VK desktop iframe / desktop browsers: plain anchor downloads. - Mobile browsers: the OS share sheet (fetch-then-share). - Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option. The PNG is rasterized on demand by the new internal `renderer` sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests; the backend rebuilds the render payload from the journal + engine.AlphabetTable, and the device date locale, IANA time zone and localized non-play labels ride the signed URL. Nothing is stored — the artifact re-derives from the immutable journal on each GET. The gateway forwards /dl/* (caddy @gateway matcher extended) behind the per-IP public rate limiter and serves bytes via http.ServeContent. Deploy: renderer service in compose + prod overlay + rolling order + prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README, renderer/README. --- .gitea/workflows/ci.yaml | 13 + .gitea/workflows/prod-deploy.yaml | 7 +- CLAUDE.md | 4 +- backend/cmd/backend/main.go | 10 + backend/internal/config/config.go | 8 + backend/internal/game/service.go | 39 +- backend/internal/render/render.go | 61 +++ backend/internal/server/export.go | 310 +++++++++++++++ backend/internal/server/export_test.go | 148 +++++++ backend/internal/server/handlers.go | 5 + backend/internal/server/server.go | 13 + deploy/.env.example | 5 + deploy/README.md | 7 +- deploy/caddy/Caddyfile | 2 +- deploy/docker-compose.prod.yml | 7 + deploy/docker-compose.yml | 36 ++ deploy/prod-deploy.sh | 1 + docs/ARCHITECTURE.md | 39 +- docs/FUNCTIONAL.md | 57 +-- docs/FUNCTIONAL_ru.md | 34 +- docs/TESTING.md | 11 +- docs/UI_DESIGN.md | 53 +-- gateway/internal/backendclient/api_social.go | 26 ++ gateway/internal/backendclient/client.go | 40 +- gateway/internal/connectsrv/server.go | 59 +++ gateway/internal/transcode/encode_social.go | 12 + .../internal/transcode/transcode_social.go | 17 + .../transcode/transcode_social_test.go | 48 +++ pkg/fbs/scrabble.fbs | 21 + pkg/fbs/scrabblefb/ExportUrl.go | 71 ++++ pkg/fbs/scrabblefb/ExportUrlRequest.go | 116 ++++++ renderer/.gitignore | 2 + renderer/Dockerfile | 29 ++ renderer/README.md | 34 ++ renderer/build.mjs | 13 + renderer/package.json | 23 ++ renderer/pnpm-lock.yaml | 366 ++++++++++++++++++ renderer/pnpm-workspace.yaml | 6 + renderer/src/entry.ts | 6 + renderer/src/render.mjs | 24 ++ renderer/src/server.mjs | 55 +++ renderer/test/render.test.mjs | 37 ++ renderer/testdata/request.json | 1 + ui/e2e/export.spec.ts | 206 ++++++++-- ui/src/game/Game.svelte | 113 ++++-- ui/src/gen/fbs/scrabblefb.ts | 2 + .../gen/fbs/scrabblefb/export-url-request.ts | 113 ++++++ ui/src/gen/fbs/scrabblefb/export-url.ts | 60 +++ ui/src/lib/client.ts | 5 + ui/src/lib/codec.test.ts | 26 ++ ui/src/lib/codec.ts | 31 ++ ui/src/lib/gameimage.ts | 64 +-- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/mock/client.ts | 7 + ui/src/lib/model.ts | 8 + ui/src/lib/retry.ts | 1 + ui/src/lib/share.test.ts | 45 +-- ui/src/lib/share.ts | 62 +-- ui/src/lib/telegram.ts | 27 +- ui/src/lib/transport.ts | 5 + ui/src/lib/vk.ts | 30 ++ 62 files changed, 2449 insertions(+), 234 deletions(-) create mode 100644 backend/internal/render/render.go create mode 100644 backend/internal/server/export.go create mode 100644 backend/internal/server/export_test.go create mode 100644 pkg/fbs/scrabblefb/ExportUrl.go create mode 100644 pkg/fbs/scrabblefb/ExportUrlRequest.go create mode 100644 renderer/.gitignore create mode 100644 renderer/Dockerfile create mode 100644 renderer/README.md create mode 100644 renderer/build.mjs create mode 100644 renderer/package.json create mode 100644 renderer/pnpm-lock.yaml create mode 100644 renderer/pnpm-workspace.yaml create mode 100644 renderer/src/entry.ts create mode 100644 renderer/src/render.mjs create mode 100644 renderer/src/server.mjs create mode 100644 renderer/test/render.test.mjs create mode 100644 renderer/testdata/request.json create mode 100644 ui/src/gen/fbs/scrabblefb/export-url-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/export-url.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 909d159..d124743 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -73,6 +73,9 @@ jobs: go=false; ui=false if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi if echo "$files" | grep -qE '^ui/'; then ui=true; fi + # The render sidecar bundles ui/src/lib, so its dir rides the ui lane (the + # deploy's compose build picks it up either way). + if echo "$files" | grep -qE '^renderer/'; then ui=true; fi # A workflow or deploy change re-runs everything as a safety net. if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi else @@ -199,6 +202,14 @@ jobs: - name: Bundle-size budget run: node scripts/bundle-size.mjs + # The render sidecar executes the shared ui/src/lib/gameimage.ts on skia-canvas; + # its smoke test guards the bundling + skia seam (docs/TESTING.md). + - name: Render sidecar test + working-directory: renderer + run: | + pnpm install --frozen-lockfile + pnpm test + - name: Install Playwright browsers run: pnpm exec playwright install chromium webkit timeout-minutes: 5 @@ -319,6 +330,8 @@ jobs: # VK Mini App protected key (offline HMAC for the launch-param signature); empty # leaves the VK auth path (auth.vk) disabled until the operator sets the secret. GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }} + # Signs the finished-game export download URLs (backend + compose interpolation). + EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }} GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index af0fcbd..6c80e3f 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -59,10 +59,10 @@ jobs: working-directory: deploy run: | export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=. - # The four main-stack images via compose (reuses the build args, incl. VERSION); + # The main-stack images via compose (reuses the build args, incl. VERSION); # the bot separately, since it is profiled out of the prod compose. docker compose -f docker-compose.yml -f docker-compose.prod.yml build - docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator + docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator renderer docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" .. docker push "$REGISTRY/scrabble-telegram-bot:$TAG" @@ -84,6 +84,8 @@ jobs: GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }} + # Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY). + EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }} PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} @@ -139,6 +141,7 @@ jobs: export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' + export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY' export GATEWAY_ABUSE_BAN_ENABLED='true' EOF printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt diff --git a/CLAUDE.md b/CLAUDE.md index 75321f4..dc03378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,9 +125,10 @@ gateway/ # module scrabble/gateway: Connect-RPC edge, embeds ui/ # Svelte + Vite SPA + landing (Node project, not in go.work) pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link) +renderer/ # image-render sidecar (Node + skia-canvas): runs ui/src/lib/gameimage.ts server-side for the finished-game PNG export loadtest/ # module scrabble/loadtest: the load/stress harness docs/ .gitea/workflows/ CLAUDE.md README.md -backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets +backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile renderer/Dockerfile # multi-stage distroless (renderer: node:22-slim + skia-canvas + fonts); gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh ``` @@ -143,6 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080 cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI pnpm start # UI mock mode: lobby -> game, no backend +cd renderer && pnpm install && pnpm test # image-render sidecar (bundles ui/src/lib/gameimage.ts, skia smoke) docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway . diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 5db95db..19796d6 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -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) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d7151d6..d80182d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 312f93d..bd9081e 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -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 } diff --git a/backend/internal/render/render.go b/backend/internal/render/render.go new file mode 100644 index 0000000..958b6d8 --- /dev/null +++ b/backend/internal/render/render.go @@ -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 +} diff --git a/backend/internal/server/export.go b/backend/internal/server/export.go new file mode 100644 index 0000000..8e4dbd8 --- /dev/null +++ b/backend/internal/server/export.go @@ -0,0 +1,310 @@ +// 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=&tz=&t= +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. Content-Length is set +// explicitly: a body over net/http's write buffer otherwise goes out chunked, and +// Android's system DownloadManager (which VKWebAppDownloadFile delegates to) hangs +// forever on a download of unknown length. +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.Header("Content-Length", strconv.Itoa(len(data))) + 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 +} diff --git a/backend/internal/server/export_test.go b/backend/internal/server/export_test.go new file mode 100644 index 0000000..ed2fd85 --- /dev/null +++ b/backend/internal/server/export_test.go @@ -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) + } + } +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index d15fb59..6edd670 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 03af2a6..271c473 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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() diff --git a/deploy/.env.example b/deploy/.env.example index 376f154..475b30c 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -21,6 +21,11 @@ DICT_VERSION=v1.3.0 # --- Logging ---------------------------------------------------------------- LOG_LEVEL=info +# --- Finished-game export ---------------------------------------------------- +# HMAC key signing the public export download URLs (/dl/*). Required; generate a +# real contour value with `openssl rand -base64 32` (Gitea TEST_/PROD_EXPORT_SIGN_KEY). +EXPORT_SIGN_KEY=dev-export-sign-key + # --- Edge / caddy ----------------------------------------------------------- # Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the # external `edge` network). Prod: a domain so caddy does its own ACME. diff --git a/deploy/README.md b/deploy/README.md index e4e463a..3d5db7f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -16,6 +16,7 @@ operational reference for **every environment variable**. | `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. | | `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. | | `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). | +| `renderer` | built (`renderer/Dockerfile`) | Finished-game image-render sidecar (Node + skia-canvas running the shared `ui/src/lib/gameimage.ts`); internal-only HTTP at `renderer:8090`, called by the backend for the PNG export artifact. | | `validator` | built (`platform/telegram/Dockerfile`, target `validator`) | Telegram HMAC validator (no VPN, no Bot API); internal gRPC at `validator:9091`. Game login depends only on this. | | `vpn` + `bot` | sidecar + built (`platform/telegram/Dockerfile`, target `bot`) | Telegram bot, gated to the **`telegram-local`** profile; egresses through the AmneziaWG sidecar and dials the gateway bot-link (mTLS) at `gateway:9443`. The test contour activates the profile; the prod **main** host omits it and runs the bot standalone on its **own host** (`docker-compose.bot.yml`, no VPN — native Bot API egress). | | `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. | @@ -62,6 +63,7 @@ compose binds from this directory. | `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). | | `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext ''`. | | `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. | +| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. | **Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at @@ -106,7 +108,8 @@ at each other on the `internal` network — listed here so they are not mistaken missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`), `GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`), `GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway -bot-link relay `gateway:9092`), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`, +bot-link relay `gateway:9092`), `BACKEND_RENDERER_URL` (→ `renderer:8090`, the image-render +sidecar), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`, mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each deploy), and all services' `*_OTEL_*_EXPORTER=otlp` → @@ -194,7 +197,7 @@ players arrive. **`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets: `PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN, -TELEGRAM_PROMO_BOT_TOKEN, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA, +TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; variables: `PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID, diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index ffb17ed..beef9bf 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -53,7 +53,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /metrics/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index cc5c82b..434eaec 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -50,6 +50,13 @@ services: limits: memory: 384M + renderer: + image: ${REGISTRY:?set REGISTRY}/scrabble-renderer:${TAG:?set TAG} + deploy: + resources: + limits: + memory: 160M + validator: image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG} deploy: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5ea740a..163afdc 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -61,6 +61,36 @@ services: memory: 512M networks: [internal] + # The finished-game image-render sidecar: internal-only, called by the backend for the + # PNG export artifact. It runs the same ui/src/lib/gameimage.ts the web client + # unit-tests, on skia-canvas (renderer/README.md). node:22-slim carries a shell, so — + # uniquely among the built services — it has a real container healthcheck the backend's + # depends_on gates on. + renderer: + container_name: scrabble-renderer + image: scrabble-renderer:latest + build: + context: .. + dockerfile: renderer/Dockerfile + args: + VERSION: ${APP_VERSION:-dev} + restart: unless-stopped + logging: *default-logging + environment: + RENDERER_PORT: "8090" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 3s + retries: 12 + # A render peaks well under 100 MiB (a 2-MP canvas + skia); idle sits near 60 MiB. + deploy: + resources: + limits: + cpus: "1.0" + memory: 192M + networks: [internal] + backend: container_name: scrabble-backend image: scrabble-backend:latest @@ -80,9 +110,15 @@ services: depends_on: postgres: condition: service_healthy + renderer: + condition: service_healthy environment: # search_path=backend matches the migrations (00001 creates the schema). BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend + # The finished-game export: the render sidecar address + the HMAC key signing the + # public download URLs (deploy env: TEST_/PROD_EXPORT_SIGN_KEY). + BACKEND_RENDERER_URL: http://renderer:8090 + BACKEND_EXPORT_SIGN_KEY: ${EXPORT_SIGN_KEY:?set EXPORT_SIGN_KEY — the export download URL signing secret} # The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom # for bursts. Postgres (2 cores / 512 MiB) handles it. BACKEND_POSTGRES_MAX_OPEN_CONNS: "40" diff --git a/deploy/prod-deploy.sh b/deploy/prod-deploy.sh index af87062..012260d 100755 --- a/deploy/prod-deploy.sh +++ b/deploy/prod-deploy.sh @@ -134,6 +134,7 @@ fi # Roll one service at a time, least -> most dependent; any failure rolls everything back. roll postgres health_postgres || { rollback; exit 1; } +roll renderer health_running scrabble-renderer || { rollback; exit 1; } roll backend health_backend || { rollback; exit 1; } roll gateway health_running scrabble-gateway || { rollback; exit 1; } roll landing health_landing || { rollback; exit 1; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a5cd761..5fa3631 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -785,11 +785,40 @@ the same rows and is likewise self-contained — we ship our own writer (the sol exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` exchanges), plus `#note` lines for resignations and timeouts, which the standard -does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive` -otherwise), so an in-progress journal is never leaked mid-play; the client -shares the `.gcg` file via the Web Share API where available; an Android in-app WebView -(Telegram / VK) has no Web Share and silently ignores an ``, so there it copies the GCG -text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file. +does not cover. **Export is offered only on a finished game** (`game.ErrGameActive` +otherwise), so an in-progress journal is never leaked mid-play. + +**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text +and the rendered **PNG of the final position** — travel one uniform route on every +platform: the client calls the authenticated `game.export_url` op, the backend mints a +**relative, HMAC-signed, short-lived path** +(`/dl/{game}/{kind}?e=&…&s=`, 10-minute TTL, +`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no +service ever needs to know the public host) and hands it to the best affordance +each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the +chooser there is the native showPopup — activation-free bridge calls end to end), +Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies +the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both +formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its +DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a +mobile browser, or a plain anchor download (desktop browsers and the VK desktop +iframe). The gateway serves the bytes via +`http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager +hangs on chunked bodies of unknown length). The GET is the gateway's +**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the +per-IP public rate limiter): the native download calls carry no cookies or headers, so +the URL's signature — verified by the backend on its `/api/v1/public` group in constant +time, every failure a uniform 404 — is the whole grant, and minting requires an +authenticated caller on a finished game. The PNG is rasterized on demand by the +**`renderer` sidecar** (internal-only Node + skia-canvas running the same +`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift; +`renderer/README.md`); the backend rebuilds the render payload from the journal + +`engine.AlphabetTable`, and the client's date locale, IANA time zone and UI-localized +non-play labels ride the signed URL so the server render matches the player's +presentation. Nothing is stored: the artifact is re-derived from the immutable +finished journal on each GET. The only degraded platform is a legacy Telegram client +predating `downloadFile`, where the GCG falls back to the old clipboard copy and the +image option is not offered. The alphabet-on-the-wire transport does **not** touch this invariant: the live edge exchanges alphabet indices, but the persisted journal (and everything derived from it — diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 989ff51..2ab09fa 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -322,33 +322,40 @@ Finished games are archived in a dictionary-independent form and exportable in **two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of the final position; the export is offered **only once a game is finished**, and never for an honest-AI practice game (a live game's export would leak the move -journal; an AI game is throwaway). The format chooser is always the app's **own -modal** — deliberately not Telegram's native popup, whose callback runs without -user activation and silently breaks the share/clipboard delivery it leads to. +journal; an AI game is throwaway). The format chooser is Telegram's **native popup** +on Telegram Android/desktop (safe there: that chain is all bridge calls, which need +no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery +is the OS share sheet, which a native-popup callback cannot open, and VK has no +native chooser. -The **image** is rendered on the client (Canvas 2D, always the light theme): the -final board with classic coordinate axes A..O / 1..15 on the left — premium squares -as plain colour fills, no text labels — and a compact per-seat scoresheet on the -right: each seat's name and final score in the header (🏆 by the winner), then one -row per move carrying the main word's classic coordinate (an across play is -row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and -its points; extra words of a multi-word play ride a smaller second line, non-play -moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row -shows the endgame rack settlement when there was one (the final scores are -authoritative — running totals alone do not include it). The footer stamps the site -host and the finish date in the device locale. The scoresheet typography is fixed; -a long game stretches the board (never below its minimum) so the image carries no -dead space. +The **image** is rendered on the server (the internal render sidecar runs the same +drawing module the web client tests; always the light theme): the final board with +classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour +fills, no text labels — and a compact per-seat scoresheet on the right: each seat's +name and final score in the header (🏆 by the winner), then one row per move +carrying the main word's classic coordinate (an across play is row-first, `8G`; a +down play column-first, `H8` — the GCG convention), the word and its points; extra +words of a multi-word play ride a smaller second line, non-play moves show as +localized notes (pass/exchange/resign/timeout), and a closing ± row shows the +endgame rack settlement when there was one (the final scores are authoritative — +running totals alone do not include it). The footer stamps the site host and the +finish date in the device locale. The scoresheet typography is fixed; a long game +stretches the board (never below its minimum) so the image carries no dead space. -Delivery per format: the **GCG** file is Web-Shared where the platform supports it; -on an Android in-app client (Telegram / VK), which has neither Web Share nor a -working file download, it copies the GCG text to the clipboard (with a confirming -toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the -same way, but a binary image has no clipboard-text fallback at all — so the image -option is currently **withheld inside the in-app webviews** (Telegram / VK) and -offered on the plain web and mobile browsers only; it returns there with the -server-rendered signed-URL delivery (Telegram `downloadFile` / -`VKWebAppDownloadFile`). Statistics (durable accounts only): +Delivery is **one signed, short-lived link for both formats on every platform**, +handed to the best affordance each platform has (each branch verified on-device): +Telegram Android/desktop use Telegram's download dialog (whose own preview shares +onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS +takes both formats through VK's download into its native share flow, while on the +**VK Android client** — whose downloader hangs — the image opens in VK's native +photo viewer (saving from its controls) and the GCG copies to the clipboard (the +desktop VK iframe, an ordinary browser, downloads both); a **mobile browser gets +the OS share sheet** (nothing lands in Downloads first); a desktop browser +downloads the file. The link needs no login to fetch (the platforms' downloaders +carry none) and is valid for minutes. The single exception is a legacy Telegram +client without the download dialog (pre-Bot API 8.0): there the GCG falls back to +the old clipboard copy (with the confirming toast) and the image option is not +offered. Statistics (durable accounts only): wins, losses, draws, max points in a game, and max points for a single move (the best play, which already includes every word it formed plus the all-tiles bonus). It also shows the player's **move count** (their plays — passes and exchanges do not diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index cecd013..2857b63 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -331,11 +331,14 @@ Telegram. **двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка** финальной позиции; экспорт доступен **только после завершения партии** и никогда — для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а -партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения, -сознательно не нативный попап Telegram: его коллбек приходит без user activation и -молча ломает доставку через share/буфер, к которой ведёт выбор. +партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на +Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов, +которым user activation не нужна) и собственный модал приложения в остальных +случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из +коллбека нативного попапа не открыть, а у VK нативного чузера нет. -**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная +**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же +модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по @@ -348,15 +351,20 @@ Telegram. и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная партия растягивает доску (не ниже минимума), так что пустот на картинке нет. -Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где -платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web -Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с -подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же, -но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт -«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается -только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером -и доставкой по подписанному URL (Telegram `downloadFile` / -`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов): +Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой +платформе**, отданная лучшему механизму, который у платформы реально есть (каждая +ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram +(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со +скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный +share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в +нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер +(десктопный iframe VK — обычный браузер — скачивает оба); **мобильный браузер +получает системную share-шторку** (ничего не оседает в Загрузках); десктопный +браузер скачивает файл. Ссылка не требует логина при +скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное +исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там +GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт +«картинка» не предлагается. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и diff --git a/docs/TESTING.md b/docs/TESTING.md index 18d0725..0d2b36a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -19,8 +19,15 @@ tests or touching CI. the FlatBuffers codecs (friend list, invitation, stats), the win-rate derivation and the GCG share/copy/download choice, plus Playwright specs against the mock for the friends screen (code issue/redeem, accept a request), the lobby - invitations section, the stats screen, profile editing, and the GCG export's - finished-only visibility. + invitations section, the stats screen, profile editing, and the export chooser's + finished-only visibility + its signed-URL download flow (route-intercepted). +- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared + `ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a + real self-played 35-move game) must rasterize to a plausible PNG + (`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout, + notation) stay unit-tested in `ui/` — the sidecar test guards only the + skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across + hosts). - **Local-eval conformance** — the client's on-device move preview (the ported dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b9b32ee..5078d9f 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -406,29 +406,36 @@ enabled on the first, uncached load) and flip in place when an event refreshes t - **History / export**: the in-game slide-down history lays each move out in a per-seat grid (the word(s) and the move score, no running total); the 📤 in the history header appears only once the game is finished — never in an honest-AI game (throwaway practice) — and - opens the **format chooser**: always the app's own modal, never Telegram's native popup - (a `showPopup` callback carries no user activation, which silently breaks the - `navigator.share` / clipboard delivery it leads to — on-device finding). The accent button - leads with the image where offered, the GCG file is the quiet alternative (GCG needs the - connection, the image renders locally). The GCG file goes by Web Share where available, a - clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor - a working download), else a Blob download. The image option shows **only outside the in-app - WebViews** for now (a binary PNG has no working route there — no Web Share, a dead - ``, a text-only clipboard, and the long-press menu mangles data: URLs); it - returns there with the server-rendered signed-URL delivery. Confirming a resign reveals - the full board: it closes the history drawer (portrait) and zooms the board out. -- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light - palette (pinned constants mirroring `app.css`), the final board with classic axes A..O / - 1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style - (bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move - highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by - the winner), one fixed-typography row per move — muted classic coordinate (across = - row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra - words of a multi-word play on a smaller second line; italic localized notes for - pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when - present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's - height drives the board side (never below its minimum): a long game stretches the board, - never the typography, so the image has no dead space. + opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe + there, since that chain is all bridge calls needing no user activation (a popup callback + must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's + own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal + click's activation; VK has no native chooser; the accent button leads with the image, + both options need the connection). Both formats mint the same signed relative link + (`game.export_url`), resolved against the app's own origin, then delivered per platform + (each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview + shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS + `VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose + DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save + works) and the GCG to the clipboard; the VK desktop iframe plain anchor downloads; a + mobile browser the OS share sheet (the proven fetch-then-share pattern); a desktop + browser an anchor download. A legacy Telegram + client without `downloadFile` keeps the old GCG clipboard copy, hides the image option + and stays on the app modal. Confirming a resign reveals the full board: it closes the + history drawer (portrait) and zooms the board out. +- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar + executes; the browser app no longer draws it): always the light palette (pinned constants + mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as + plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left, + value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is + the scoresheet: per-seat name over the final score (🏆 by the winner), one + fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down = + column-first `H8`), the main word, points right-aligned; extra words of a multi-word play + on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a + closing muted ± row for the endgame rack settlement when present. Footer: + `hostname · finish date` in the **device** locale. The scoresheet's height drives the + board side (never below its minimum): a long game stretches the board, never the + typography, so the image has no dead space. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 52c00f1..65044ba 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/url" + "strings" ) // The response structs and client methods mirror the backend's social, @@ -347,3 +348,28 @@ func (c *Client) ExportGCG(ctx context.Context, userID, gameID string) (GcgResp, err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/gcg"), userID, "", nil, &out) return out, err } + +// ExportURLResp is the minted signed download link for an export artifact. +type ExportURLResp struct { + Path string `json:"path"` + Filename string `json:"filename"` +} + +// ExportURL mints a signed download URL for a finished game's artifact (kind +// "png" or "gcg"). dateLocale and the localized non-play labels ride the URL so +// the server render matches the player's presentation. +func (c *Client) ExportURL(ctx context.Context, userID, gameID, kind, dateLocale, timeZone string, labels []string) (ExportURLResp, error) { + q := url.Values{"kind": {kind}} + if dateLocale != "" { + q.Set("dl", dateLocale) + } + if timeZone != "" { + q.Set("tz", timeZone) + } + if len(labels) > 0 { + q.Set("t", strings.Join(labels, ",")) + } + var out ExportURLResp + err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/export-url")+"?"+q.Encode(), userID, "", nil, &out) + return out, err +} diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index ce2a93e..dd7c6b5 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -39,10 +39,18 @@ const backendMaxIdleConns = 512 type Client struct { baseURL string http *http.Client - conn *grpc.ClientConn - push pushv1.PushClient + // dl serves the export downloads: the backend may wait on the render sidecar + // for several seconds, so these calls get their own, longer deadline than the + // ordinary REST budget. + dl *http.Client + conn *grpc.ClientConn + push pushv1.PushClient } +// exportDownloadTimeout bounds one export-download fetch end to end (the backend's +// own sidecar budget is 15s). +const exportDownloadTimeout = 30 * time.Second + // New dials the backend push gRPC endpoint and prepares the REST client. The // backend lives on a trusted network segment, so the gRPC connection uses // insecure (plaintext) transport credentials (ARCHITECTURE.md §12). @@ -62,11 +70,39 @@ func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) { return &Client{ baseURL: strings.TrimRight(httpURL, "/"), http: &http.Client{Timeout: timeout, Transport: transport}, + dl: &http.Client{Timeout: exportDownloadTimeout, Transport: transport}, conn: conn, push: pushv1.NewPushClient(conn), }, nil } +// ExportDownload fetches a signed finished-game export artifact from the backend's +// public group, forwarding the caller's public Host for the image footer. It returns +// the bytes with the backend's Content-Type and Content-Disposition. rest is the +// public path suffix after /dl (e.g. "//?e=…&s=…"). +func (c *Client) ExportDownload(ctx context.Context, rest, publicHost string) ([]byte, string, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/public/dl"+rest, nil) + if err != nil { + return nil, "", "", fmt.Errorf("backendclient: new request: %w", err) + } + if publicHost != "" { + req.Header.Set("X-Public-Host", publicHost) + } + resp, err := c.dl.Do(req) + if err != nil { + return nil, "", "", fmt.Errorf("backendclient: GET export: %w", err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", "", fmt.Errorf("backendclient: read export: %w", err) + } + if resp.StatusCode >= http.StatusMultipleChoices { + return nil, "", "", parseAPIError(resp.StatusCode, data) + } + return data, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Disposition"), nil +} + // Close releases the gRPC connection. func (c *Client) Close() error { return c.conn.Close() } diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index ba84d07..cfe93d3 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -7,6 +7,7 @@ package connectsrv import ( + "bytes" "context" "crypto/subtle" "encoding/json" @@ -193,6 +194,7 @@ func (s *Server) HTTPHandler() http.Handler { // The client-side local move preview pulls each game's pinned dictionary blob // through this session-gated route (not public); see dictBytesHandler. mux.Handle("/dict/", s.dictBytesHandler()) + mux.Handle("/dl/", s.exportDownloadHandler()) // The client posts its local-move-preview adoption telemetry here (session-gated). mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini @@ -415,6 +417,63 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler { // user-class limiter, and forwards the backend's immutable Cache-Control so the // browser caches the blob hard. Only GET is allowed; the path is // /dict/{variant}/{version}. +// exportDownloadHandler serves the signed finished-game export downloads (/dl/*). +// It is the gateway's only unauthenticated data route: the platforms' native +// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain anchor) +// carry no session, so the URL's HMAC — verified by the backend — is the whole +// grant. The gateway only rate-limits by IP and forwards, passing the public Host +// along for the image footer. +func (s *Server) exportDownloadHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("public:"+ip, s.publicPolicy) { + s.noteRateLimited(r.Context(), classPublic, ip, "export-dl") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + rest := strings.TrimPrefix(r.URL.Path, "/dl") + if rest == "" || rest == "/" { + http.NotFound(w, r) + return + } + if r.URL.RawQuery != "" { + rest += "?" + r.URL.RawQuery + } + data, contentType, disposition, err := s.backend.ExportDownload(r.Context(), rest, r.Host) + if err != nil { + var apiErr *backendclient.APIError + if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError { + http.NotFound(w, r) // invalid, expired or unknown link + return + } + s.log.Warn("export download failed", zap.Error(err)) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + if disposition != "" { + w.Header().Set("Content-Disposition", disposition) + } + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, max-age=0") + // ServeContent (not a bare Write): the platforms' native downloaders are picky — + // Android's system DownloadManager (the VKWebAppDownloadFile executor) hangs on a + // chunked body of unknown length and may probe with Range. ServeContent emits + // Content-Length, honours Range/If-* and answers 206s from the buffered artifact. + http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) + }) +} + func (s *Server) dictBytesHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.backend == nil { diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index 9a136fb..def51e8 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -251,6 +251,18 @@ func encodeInvitationList(r backendclient.InvitationListResp) []byte { return b.FinishedBytes() } +// encodeExportURL builds an ExportUrl payload. +func encodeExportURL(r backendclient.ExportURLResp) []byte { + b := flatbuffers.NewBuilder(256) + path := b.CreateString(r.Path) + fn := b.CreateString(r.Filename) + fb.ExportUrlStart(b) + fb.ExportUrlAddPath(b, path) + fb.ExportUrlAddFilename(b, fn) + b.Finish(fb.ExportUrlEnd(b)) + return b.FinishedBytes() +} + // encodeGcg builds a GcgExport payload. func encodeGcg(r backendclient.GcgResp) []byte { b := flatbuffers.NewBuilder(1024) diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index e0125cd..672e72e 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -31,6 +31,7 @@ const ( MsgProfileUpdate = "profile.update" MsgStatsGet = "stats.get" MsgGameGCG = "game.gcg" + MsgGameExportURL = "game.export_url" ) // registerSocialOps adds the social, account and history operations to the @@ -56,6 +57,7 @@ func registerSocialOps(r *Registry, backend *backendclient.Client) { r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true} r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true} r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true} + r.ops[MsgGameExportURL] = Op{Handler: exportURLHandler(backend), Auth: true} } // --- friends --- @@ -290,6 +292,21 @@ func gcgHandler(backend *backendclient.Client) Handler { } } +func exportURLHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsExportUrlRequest(req.Payload, 0) + labels := make([]string, 0, in.ActionLabelsLength()) + for i := range in.ActionLabelsLength() { + labels = append(labels, string(in.ActionLabels(i))) + } + res, err := backend.ExportURL(ctx, req.UserID, string(in.GameId()), string(in.Kind()), string(in.DateLocale()), string(in.TimeZone()), labels) + if err != nil { + return nil, err + } + return encodeExportURL(res), nil + } +} + // decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest. func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string { n := in.InviteeIdsLength() diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index 96206a1..f79136f 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -263,6 +263,54 @@ func TestGcgRoundTrip(t *testing.T) { } } +func TestExportURLRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/games/g-1/export-url" { + t.Errorf("unexpected path %q", r.URL.Path) + } + q := r.URL.Query() + if q.Get("kind") != "png" || q.Get("dl") != "ru-RU" || q.Get("tz") != "Europe/Moscow" || q.Get("t") != "пас,обмен,сдался,таймаут" { + t.Errorf("unexpected query %q", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"path":"/dl/g-1/png?e=1&s=x","filename":"game-g-1.png"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameExportURL) + + b := flatbuffers.NewBuilder(256) + gid := b.CreateString("g-1") + kind := b.CreateString("png") + dl := b.CreateString("ru-RU") + tz := b.CreateString("Europe/Moscow") + labels := make([]flatbuffers.UOffsetT, 0, 4) + for _, s := range []string{"пас", "обмен", "сдался", "таймаут"} { + labels = append(labels, b.CreateString(s)) + } + fb.ExportUrlRequestStartActionLabelsVector(b, len(labels)) + for i := len(labels) - 1; i >= 0; i-- { + b.PrependUOffsetT(labels[i]) + } + vec := b.EndVector(len(labels)) + fb.ExportUrlRequestStart(b) + fb.ExportUrlRequestAddGameId(b, gid) + fb.ExportUrlRequestAddKind(b, kind) + fb.ExportUrlRequestAddDateLocale(b, dl) + fb.ExportUrlRequestAddActionLabels(b, vec) + fb.ExportUrlRequestAddTimeZone(b, tz) + b.Finish(fb.ExportUrlRequestEnd(b)) + + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()}) + if err != nil { + t.Fatalf("handler: %v", err) + } + res := fb.GetRootAsExportUrl(payload, 0) + if string(res.Path()) != "/dl/g-1/png?e=1&s=x" || string(res.Filename()) != "game-g-1.png" { + t.Fatalf("export url decoded wrong: path=%q filename=%q", res.Path(), res.Filename()) + } +} + func TestProfileUpdateRoundTripAway(t *testing.T) { var gotBody map[string]any backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index a23da6a..de830fc 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -658,6 +658,27 @@ table GcgExport { content:string; } +// ExportUrlRequest asks for a signed download URL of a finished game's export artifact. +// kind is "png" or "gcg". For the PNG the client passes its presentation context — the +// UI-localized non-play move labels (pass, exchange, resign, timeout, in that order), +// the device date locale and the device IANA time zone — which ride the signed URL so +// the server render matches what the player would have seen locally. +table ExportUrlRequest { + game_id:string; + kind:string; + date_locale:string; + action_labels:[string]; + time_zone:string; +} + +// ExportUrl is the minted download link: a relative, signed, short-lived path the client +// resolves against its own origin (the SPA and the gateway share it), plus the filename +// the download will carry. +table ExportUrl { + path:string; + filename:string; +} + // --- push event payloads --- // YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the diff --git a/pkg/fbs/scrabblefb/ExportUrl.go b/pkg/fbs/scrabblefb/ExportUrl.go new file mode 100644 index 0000000..9c61e57 --- /dev/null +++ b/pkg/fbs/scrabblefb/ExportUrl.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type ExportUrl struct { + _tab flatbuffers.Table +} + +func GetRootAsExportUrl(buf []byte, offset flatbuffers.UOffsetT) *ExportUrl { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ExportUrl{} + x.Init(buf, n+offset) + return x +} + +func FinishExportUrlBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsExportUrl(buf []byte, offset flatbuffers.UOffsetT) *ExportUrl { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ExportUrl{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedExportUrlBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *ExportUrl) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ExportUrl) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ExportUrl) Path() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExportUrl) Filename() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func ExportUrlStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func ExportUrlAddPath(builder *flatbuffers.Builder, path flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(path), 0) +} +func ExportUrlAddFilename(builder *flatbuffers.Builder, filename flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(filename), 0) +} +func ExportUrlEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/ExportUrlRequest.go b/pkg/fbs/scrabblefb/ExportUrlRequest.go new file mode 100644 index 0000000..37b8083 --- /dev/null +++ b/pkg/fbs/scrabblefb/ExportUrlRequest.go @@ -0,0 +1,116 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type ExportUrlRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsExportUrlRequest(buf []byte, offset flatbuffers.UOffsetT) *ExportUrlRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ExportUrlRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishExportUrlRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsExportUrlRequest(buf []byte, offset flatbuffers.UOffsetT) *ExportUrlRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ExportUrlRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedExportUrlRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *ExportUrlRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ExportUrlRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ExportUrlRequest) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExportUrlRequest) Kind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExportUrlRequest) DateLocale() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExportUrlRequest) ActionLabels(j int) []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + } + return nil +} + +func (rcv *ExportUrlRequest) ActionLabelsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *ExportUrlRequest) TimeZone() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func ExportUrlRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func ExportUrlRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func ExportUrlRequestAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(kind), 0) +} +func ExportUrlRequestAddDateLocale(builder *flatbuffers.Builder, dateLocale flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(dateLocale), 0) +} +func ExportUrlRequestAddActionLabels(builder *flatbuffers.Builder, actionLabels flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actionLabels), 0) +} +func ExportUrlRequestStartActionLabelsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func ExportUrlRequestAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(timeZone), 0) +} +func ExportUrlRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/renderer/.gitignore b/renderer/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/renderer/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/renderer/Dockerfile b/renderer/Dockerfile new file mode 100644 index 0000000..d083897 --- /dev/null +++ b/renderer/Dockerfile @@ -0,0 +1,29 @@ +# renderer — the finished-game image-render sidecar: Node + skia-canvas running the SAME +# ui/src/lib/gameimage.ts the web client unit-tests, bundled at build time (renderer/README.md). +# Debian slim, not the repo's usual alpine/distroless: skia-canvas ships prebuilt glibc +# binaries and resolves fonts through fontconfig, and the runtime fonts are baked into the +# image — Liberation Sans (the UI font stack's Linux face) + Noto Color Emoji (the 🏆). +FROM node:22-slim AS build +WORKDIR /src/renderer +RUN corepack enable && corepack prepare pnpm@11.0.9 --activate +COPY renderer/package.json renderer/pnpm-lock.yaml renderer/pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY renderer/build.mjs ./ +COPY renderer/src ./src +COPY ui/src/lib /src/ui/src/lib +RUN pnpm run bundle + +FROM node:22-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends fonts-liberation fonts-noto-color-emoji \ + && rm -rf /var/lib/apt/lists/* +ENV NODE_ENV=production +ARG VERSION=dev +ENV APP_VERSION=${VERSION} +WORKDIR /app +COPY --from=build /src/renderer/node_modules ./node_modules +COPY --from=build /src/renderer/dist ./dist +COPY renderer/src/server.mjs renderer/src/render.mjs ./src/ +USER node +EXPOSE 8090 +CMD ["node", "src/server.mjs"] diff --git a/renderer/README.md b/renderer/README.md new file mode 100644 index 0000000..79a25e4 --- /dev/null +++ b/renderer/README.md @@ -0,0 +1,34 @@ +# renderer — the finished-game image-render sidecar + +An internal-only Node service that rasterizes the finished-game export PNG. It runs the +**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image +build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on +[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is +pixel-identical to the design the owner signed off in the browser. + +## Interface + +- `POST /render` — `{game, moves, alphabet, labels, dateLocale, hostname, scale?}` → + `image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`); + `alphabet` is the per-variant `(index, letter, value)` table tile values are drawn + from; `labels` localizes the non-play moves (pass/exchange/resign/timeout); + `hostname` + `dateLocale` feed the footer. +- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on). + +The service draws and nothing else: authentication, the participant check and the signed +public download URL all live in the backend (`backend/internal/server/export.go`); the +network path is client → gateway `/dl/*` → backend → this sidecar. + +## Development + +```sh +pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml + # (allowBuilds) or the native binary is silently never fetched +pnpm test # bundles, then node --test against testdata/request.json +node src/server.mjs # local run on :8090 (RENDERER_PORT overrides) +``` + +The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the +UI font stack's Linux face) and Noto Color Emoji (the scoresheet 🏆); skia-canvas picks +both up through fontconfig. The fixture `testdata/request.json` is a real 35-move +self-played Russian Scrabble game. diff --git a/renderer/build.mjs b/renderer/build.mjs new file mode 100644 index 0000000..23423c6 --- /dev/null +++ b/renderer/build.mjs @@ -0,0 +1,13 @@ +// Bundles the shared ui/src/lib game-image renderer (see src/entry.ts) into +// dist/gameimage.mjs for the sidecar runtime. Uses the esbuild JS API — the CLI shim is +// unreliable under pnpm's bin wrapper (it re-runs the native binary through node). +import { build } from 'esbuild'; + +await build({ + entryPoints: ['src/entry.ts'], + bundle: true, + format: 'esm', + platform: 'node', + outfile: 'dist/gameimage.mjs', + logLevel: 'info', +}); diff --git a/renderer/package.json b/renderer/package.json new file mode 100644 index 0000000..c791dfd --- /dev/null +++ b/renderer/package.json @@ -0,0 +1,23 @@ +{ + "name": "scrabble-renderer", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Internal sidecar that renders the finished-game export PNG with the same ui/src/lib/gameimage.ts the browser build tests, on skia-canvas.", + "scripts": { + "bundle": "node build.mjs", + "test": "pnpm run bundle && node --test test/*.test.mjs" + }, + "dependencies": { + "skia-canvas": "^3.0.6" + }, + "devDependencies": { + "esbuild": "^0.25.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "skia-canvas" + ] + } +} diff --git a/renderer/pnpm-lock.yaml b/renderer/pnpm-lock.yaml new file mode 100644 index 0000000..32027d3 --- /dev/null +++ b/renderer/pnpm-lock.yaml @@ -0,0 +1,366 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + skia-canvas: + specifier: ^3.0.6 + version: 3.0.8 + devDependencies: + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + parenthesis@3.1.8: + resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==} + + skia-canvas@3.0.8: + resolution: {integrity: sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==} + + string-split-by@1.0.0: + resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + agent-base@7.1.4: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + follow-redirects@1.16.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ms@2.1.3: {} + + parenthesis@3.1.8: {} + + skia-canvas@3.0.8: + dependencies: + detect-libc: 2.1.2 + follow-redirects: 1.16.0 + https-proxy-agent: 7.0.6 + string-split-by: 1.0.0 + transitivePeerDependencies: + - debug + - supports-color + + string-split-by@1.0.0: + dependencies: + parenthesis: 3.1.8 diff --git a/renderer/pnpm-workspace.yaml b/renderer/pnpm-workspace.yaml new file mode 100644 index 0000000..9551a6c --- /dev/null +++ b/renderer/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# pnpm 11 records build-script approval here. esbuild's postinstall materialises its CLI +# shim; skia-canvas's postinstall downloads the prebuilt native .node binary — without the +# approval it is silently skipped and the service fails at boot. +allowBuilds: + esbuild: true + skia-canvas: true diff --git a/renderer/src/entry.ts b/renderer/src/entry.ts new file mode 100644 index 0000000..2b1a954 --- /dev/null +++ b/renderer/src/entry.ts @@ -0,0 +1,6 @@ +// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib — +// the exact module the browser build unit-tests — plus the alphabet cache seeder the +// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle` +// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source). +export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage'; +export { setAlphabet } from '../../ui/src/lib/alphabet'; diff --git a/renderer/src/render.mjs b/renderer/src/render.mjs new file mode 100644 index 0000000..1823ff9 --- /dev/null +++ b/renderer/src/render.mjs @@ -0,0 +1,24 @@ +// The render core, separated from the HTTP wiring so the node test can call it directly. +import { Canvas } from 'skia-canvas'; +import { drawGameImage, setAlphabet } from '../dist/gameimage.mjs'; + +/** + * renderRequest rasterizes one export request — the JSON shape the backend sends: + * { game, moves, alphabet, labels, dateLocale, hostname, scale? } — into a PNG buffer. + * game/moves are the ui-model shapes (GameView / MoveRecord[]) the shared drawGameImage + * consumes; alphabet is the per-variant (index, letter, value) table it scores tiles with. + */ +export async function renderRequest(payload) { + const { game, moves, alphabet, labels, dateLocale, timeZone, hostname, scale } = payload; + if (!game || !Array.isArray(moves)) throw Object.assign(new Error('bad payload'), { status: 400 }); + setAlphabet(game.variant, alphabet ?? []); + const canvas = new Canvas(1, 1); + drawGameImage(canvas, game, moves, { + actionLabel: (a) => (labels && labels[a]) || a, + hostname: hostname || '', + dateLocale: dateLocale || undefined, + timeZone: timeZone || undefined, + scale: scale || 2, + }); + return canvas.toBuffer('png'); +} diff --git a/renderer/src/server.mjs b/renderer/src/server.mjs new file mode 100644 index 0000000..84ea98c --- /dev/null +++ b/renderer/src/server.mjs @@ -0,0 +1,55 @@ +// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the +// finished-game export image. It runs the same drawGameImage the browser build ships +// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from +// the image (Liberation Sans + Noto Color Emoji via fontconfig). +// +// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png +// GET /healthz → 200 +// +// The service is internal-only (docker network `internal`); authentication, participant +// checks and the signed public URL all live in the backend — this process only draws. +import { createServer } from 'node:http'; +import { renderRequest } from './render.mjs'; + +const PORT = Number(process.env.RENDERER_PORT || 8090); +// A render request is a finished game's journal — generously capped. +const MAX_BODY = 2 * 1024 * 1024; + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (c) => { + size += c.length; + if (size > MAX_BODY) { + reject(Object.assign(new Error('body too large'), { status: 413 })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +const server = createServer(async (req, res) => { + try { + if (req.method === 'GET' && req.url === '/healthz') { + res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); + return; + } + if (req.method === 'POST' && req.url === '/render') { + const png = await renderRequest(JSON.parse(await readBody(req))); + res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png); + return; + } + res.writeHead(404).end(); + } catch (err) { + const status = err.status || (err instanceof SyntaxError ? 400 : 500); + console.error(`render error: ${err.message}`); + res.writeHead(status, { 'content-type': 'text/plain' }).end('render failed'); + } +}); + +server.listen(PORT, () => console.log(`renderer listening on :${PORT}`)); diff --git a/renderer/test/render.test.mjs b/renderer/test/render.test.mjs new file mode 100644 index 0000000..50a794f --- /dev/null +++ b/renderer/test/render.test.mjs @@ -0,0 +1,37 @@ +// Smoke test for the render core: feeds the committed fixture (a real 35-move self-played +// Russian Scrabble game) through renderRequest and asserts a sane PNG comes back. Pixel +// goldens are deliberately avoided — font rasterization differs across hosts; the shared +// drawing logic itself is unit-tested in ui/ (gameimage.test.ts). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { renderRequest } from '../src/render.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixture = () => JSON.parse(readFileSync(join(here, '../testdata/request.json'), 'utf8')); + +function pngSize(buf) { + // IHDR is the first chunk: width/height are big-endian u32 at offsets 16/20. + return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }; +} + +test('renders the fixture game to a plausible PNG', async () => { + const png = await renderRequest(fixture()); + assert.ok(png.length > 50_000, `png too small: ${png.length}`); + assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const { w, h } = pngSize(png); + // scale 2 over a MIN_SIDE 620 board + two seat columns: sanity bounds, not a golden. + assert.ok(w > 1600 && w < 3000, `unexpected width ${w}`); + assert.ok(h > 1000 && h < 3000, `unexpected height ${h}`); +}); + +test('rejects a payload without a game', async () => { + await assert.rejects(() => renderRequest({ moves: [] }), /bad payload/); +}); + +test('renders concurrently without cross-talk', async () => { + const [a, b] = await Promise.all([renderRequest(fixture()), renderRequest(fixture())]); + assert.equal(a.length, b.length); +}); diff --git a/renderer/testdata/request.json b/renderer/testdata/request.json new file mode 100644 index 0000000..da5776b --- /dev/null +++ b/renderer/testdata/request.json @@ -0,0 +1 @@ +{"game":{"id":"selfplay-sample","variant":"scrabble_ru","dictVersion":"","status":"finished","players":2,"toMove":0,"turnTimeoutSecs":0,"multipleWordsPerTurn":true,"moveCount":35,"endReason":"standard","lastActivityUnix":1782997629,"vsAi":false,"unreadChat":false,"unreadMessages":false,"seats":[{"seat":0,"accountId":"","displayName":"Анна","score":398,"hintsUsed":0,"isWinner":true},{"seat":1,"accountId":"","displayName":"Виктор","score":294,"hintsUsed":0,"isWinner":false}]},"moves":[{"player":0,"action":"play","dir":"H","mainRow":7,"mainCol":7,"tiles":[{"row":7,"col":7,"letter":"Т","blank":false},{"row":7,"col":8,"letter":"О","blank":false},{"row":7,"col":9,"letter":"К","blank":false},{"row":7,"col":10,"letter":"А","blank":false},{"row":7,"col":11,"letter":"Й","blank":false}],"words":["ТОКАЙ"],"count":0,"score":26,"total":26},{"player":1,"action":"play","dir":"H","mainRow":8,"mainCol":7,"tiles":[{"row":8,"col":7,"letter":"Э","blank":false},{"row":8,"col":8,"letter":"Р","blank":false}],"words":["ЭР","ТЭ","ОР"],"count":0,"score":22,"total":22},{"player":0,"action":"play","dir":"H","mainRow":9,"mainCol":5,"tiles":[{"row":9,"col":5,"letter":"Ф","blank":false},{"row":9,"col":6,"letter":"А","blank":false},{"row":9,"col":7,"letter":"К","blank":false},{"row":9,"col":8,"letter":"Т","blank":false}],"words":["ФАКТ","ТЭК","ОРТ"],"count":0,"score":48,"total":74},{"player":1,"action":"play","dir":"V","mainRow":7,"mainCol":5,"tiles":[{"row":7,"col":5,"letter":"Т","blank":false},{"row":8,"col":5,"letter":"И","blank":false},{"row":10,"col":5,"letter":"И","blank":false},{"row":11,"col":5,"letter":"Я","blank":false}],"words":["ТИФИЯ"],"count":0,"score":16,"total":38},{"player":0,"action":"play","dir":"H","mainRow":10,"mainCol":8,"tiles":[{"row":10,"col":8,"letter":"О","blank":false},{"row":10,"col":9,"letter":"Д","blank":false},{"row":10,"col":10,"letter":"Е","blank":false},{"row":10,"col":11,"letter":"О","blank":false},{"row":10,"col":12,"letter":"Н","blank":false}],"words":["ОДЕОН","ОРТО"],"count":0,"score":16,"total":90},{"player":1,"action":"play","dir":"V","mainRow":5,"mainCol":9,"tiles":[{"row":5,"col":9,"letter":"У","blank":false},{"row":6,"col":9,"letter":"Р","blank":false},{"row":8,"col":9,"letter":"А","blank":false}],"words":["УРКА","ЭРА"],"count":0,"score":20,"total":58},{"player":0,"action":"play","dir":"V","mainRow":10,"mainCol":4,"tiles":[{"row":10,"col":4,"letter":"П","blank":false},{"row":11,"col":4,"letter":"Е","blank":false},{"row":12,"col":4,"letter":"Р","blank":false},{"row":13,"col":4,"letter":"Ь","blank":false},{"row":14,"col":4,"letter":"Ё","blank":false}],"words":["ПЕРЬЁ","ПИ","ЕЯ"],"count":0,"score":30,"total":120},{"player":1,"action":"play","dir":"H","mainRow":4,"mainCol":9,"tiles":[{"row":4,"col":9,"letter":"П","blank":false},{"row":4,"col":10,"letter":"Ю","blank":false},{"row":4,"col":11,"letter":"И","blank":false}],"words":["ПЮИ","ПУРКА"],"count":0,"score":30,"total":88},{"player":0,"action":"play","dir":"H","mainRow":11,"mainCol":11,"tiles":[{"row":11,"col":11,"letter":"С","blank":false},{"row":11,"col":12,"letter":"М","blank":false},{"row":11,"col":13,"letter":"О","blank":false},{"row":11,"col":14,"letter":"Г","blank":false}],"words":["СМОГ","ОС","НМ"],"count":0,"score":27,"total":147},{"player":1,"action":"play","dir":"H","mainRow":7,"mainCol":0,"tiles":[{"row":7,"col":0,"letter":"О","blank":false},{"row":7,"col":1,"letter":"Т","blank":false},{"row":7,"col":2,"letter":"С","blank":false},{"row":7,"col":3,"letter":"В","blank":false},{"row":7,"col":4,"letter":"Е","blank":false}],"words":["ОТСВЕТ"],"count":0,"score":21,"total":109},{"player":0,"action":"play","dir":"V","mainRow":4,"mainCol":4,"tiles":[{"row":4,"col":4,"letter":"Р","blank":false},{"row":5,"col":4,"letter":"Е","blank":false},{"row":6,"col":4,"letter":"З","blank":false},{"row":8,"col":4,"letter":"Ш","blank":false}],"words":["РЕЗЕШ","ШИ"],"count":0,"score":41,"total":188},{"player":1,"action":"play","dir":"H","mainRow":5,"mainCol":8,"tiles":[{"row":5,"col":8,"letter":"Б","blank":false},{"row":5,"col":10,"letter":"Р","blank":false}],"words":["БУР","ЮР"],"count":0,"score":15,"total":124},{"player":0,"action":"play","dir":"H","mainRow":3,"mainCol":11,"tiles":[{"row":3,"col":11,"letter":"З","blank":false},{"row":3,"col":12,"letter":"М","blank":false},{"row":3,"col":13,"letter":"Е","blank":false},{"row":3,"col":14,"letter":"Я","blank":false}],"words":["ЗМЕЯ","ЗИ"],"count":0,"score":40,"total":228},{"player":1,"action":"play","dir":"V","mainRow":11,"mainCol":14,"tiles":[{"row":12,"col":14,"letter":"А","blank":false},{"row":13,"col":14,"letter":"Л","blank":false},{"row":14,"col":14,"letter":"Л","blank":false}],"words":["ГАЛЛ"],"count":0,"score":24,"total":148},{"player":0,"action":"play","dir":"H","mainRow":14,"mainCol":3,"tiles":[{"row":14,"col":3,"letter":"Ж","blank":false},{"row":14,"col":5,"letter":"Н","blank":false},{"row":14,"col":6,"letter":"К","blank":false},{"row":14,"col":7,"letter":"А","blank":false}],"words":["ЖЁНКА"],"count":0,"score":51,"total":279},{"player":1,"action":"play","dir":"H","mainRow":2,"mainCol":7,"tiles":[{"row":2,"col":7,"letter":"П","blank":false},{"row":2,"col":8,"letter":"Л","blank":false},{"row":2,"col":9,"letter":"А","blank":false},{"row":2,"col":10,"letter":"В","blank":false},{"row":2,"col":11,"letter":"У","blank":true},{"row":2,"col":12,"letter":"Н","blank":false}],"words":["ПЛАВУН","УЗИ","НМ"],"count":0,"score":30,"total":178},{"player":0,"action":"play","dir":"H","mainRow":5,"mainCol":0,"tiles":[{"row":5,"col":0,"letter":"У","blank":false},{"row":5,"col":1,"letter":"Б","blank":false},{"row":5,"col":2,"letter":"И","blank":false},{"row":5,"col":3,"letter":"В","blank":false},{"row":5,"col":5,"letter":"Ц","blank":false}],"words":["УБИВЕЦ"],"count":0,"score":29,"total":308},{"player":1,"action":"play","dir":"H","mainRow":1,"mainCol":8,"tiles":[{"row":1,"col":8,"letter":"П","blank":false},{"row":1,"col":9,"letter":"Г","blank":false},{"row":1,"col":10,"letter":"Т","blank":false}],"words":["ПГТ","ПЛ","ГА","ТВ"],"count":0,"score":28,"total":206},{"player":0,"action":"play","dir":"V","mainRow":7,"mainCol":1,"tiles":[{"row":8,"col":1,"letter":"Ы","blank":false},{"row":9,"col":1,"letter":"Ч","blank":false},{"row":10,"col":1,"letter":"О","blank":false},{"row":11,"col":1,"letter":"К","blank":false}],"words":["ТЫЧОК"],"count":0,"score":23,"total":331},{"player":1,"action":"play","dir":"V","mainRow":10,"mainCol":0,"tiles":[{"row":10,"col":0,"letter":"С","blank":false},{"row":11,"col":0,"letter":"У","blank":false},{"row":12,"col":0,"letter":"Д","blank":false},{"row":13,"col":0,"letter":"Н","blank":true},{"row":14,"col":0,"letter":"О","blank":false}],"words":["СУДНО","СО","УК"],"count":0,"score":32,"total":238},{"player":0,"action":"play","dir":"V","mainRow":0,"mainCol":14,"tiles":[{"row":0,"col":14,"letter":"С","blank":false},{"row":1,"col":14,"letter":"Е","blank":false},{"row":2,"col":14,"letter":"М","blank":false}],"words":["СЕМЯ"],"count":0,"score":21,"total":352},{"player":1,"action":"play","dir":"H","mainRow":12,"mainCol":8,"tiles":[{"row":12,"col":8,"letter":"В","blank":false},{"row":12,"col":9,"letter":"Е","blank":false},{"row":12,"col":10,"letter":"Щ","blank":false},{"row":12,"col":11,"letter":"Ь","blank":false}],"words":["ВЕЩЬ","ОСЬ"],"count":0,"score":21,"total":259},{"player":0,"action":"play","dir":"H","mainRow":13,"mainCol":9,"tiles":[{"row":13,"col":9,"letter":"Д","blank":false},{"row":13,"col":10,"letter":"И","blank":false}],"words":["ДИ","ЕД","ЩИ"],"count":0,"score":25,"total":377},{"player":1,"action":"play","dir":"V","mainRow":0,"mainCol":2,"tiles":[{"row":0,"col":2,"letter":"С","blank":false},{"row":1,"col":2,"letter":"А","blank":false},{"row":2,"col":2,"letter":"Н","blank":false},{"row":3,"col":2,"letter":"Д","blank":false},{"row":4,"col":2,"letter":"Х","blank":false}],"words":["САНДХИ"],"count":0,"score":22,"total":281},{"player":0,"action":"play","dir":"H","mainRow":0,"mainCol":0,"tiles":[{"row":0,"col":0,"letter":"Н","blank":false},{"row":0,"col":1,"letter":"У","blank":false}],"words":["НУС"],"count":0,"score":12,"total":389},{"player":1,"action":"play","dir":"H","mainRow":11,"mainCol":3,"tiles":[{"row":11,"col":3,"letter":"Л","blank":false}],"words":["ЛЕЯ"],"count":0,"score":12,"total":293},{"player":0,"action":"play","dir":"H","mainRow":6,"mainCol":3,"tiles":[{"row":6,"col":3,"letter":"О","blank":false}],"words":["ОЗ","ВОВ"],"count":0,"score":9,"total":398},{"player":1,"action":"play","dir":"V","mainRow":0,"mainCol":9,"tiles":[{"row":0,"col":9,"letter":"А","blank":false}],"words":["АГА"],"count":0,"score":5,"total":298},{"player":0,"action":"play","dir":"H","mainRow":3,"mainCol":2,"tiles":[{"row":3,"col":3,"letter":"О","blank":false}],"words":["ДО"],"count":0,"score":6,"total":404},{"player":1,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":298},{"player":0,"action":"play","dir":"V","mainRow":7,"mainCol":11,"tiles":[{"row":8,"col":11,"letter":"О","blank":false}],"words":["ЙО"],"count":0,"score":5,"total":409},{"player":1,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":298},{"player":0,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":409},{"player":1,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":298},{"player":0,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":409}],"alphabet":[{"index":0,"letter":"А","value":1},{"index":1,"letter":"Б","value":3},{"index":2,"letter":"В","value":1},{"index":3,"letter":"Г","value":3},{"index":4,"letter":"Д","value":2},{"index":5,"letter":"Е","value":1},{"index":6,"letter":"Ё","value":3},{"index":7,"letter":"Ж","value":5},{"index":8,"letter":"З","value":5},{"index":9,"letter":"И","value":1},{"index":10,"letter":"Й","value":4},{"index":11,"letter":"К","value":2},{"index":12,"letter":"Л","value":2},{"index":13,"letter":"М","value":2},{"index":14,"letter":"Н","value":1},{"index":15,"letter":"О","value":1},{"index":16,"letter":"П","value":2},{"index":17,"letter":"Р","value":1},{"index":18,"letter":"С","value":1},{"index":19,"letter":"Т","value":1},{"index":20,"letter":"У","value":2},{"index":21,"letter":"Ф","value":10},{"index":22,"letter":"Х","value":5},{"index":23,"letter":"Ц","value":5},{"index":24,"letter":"Ч","value":5},{"index":25,"letter":"Ш","value":8},{"index":26,"letter":"Щ","value":10},{"index":27,"letter":"Ъ","value":10},{"index":28,"letter":"Ы","value":4},{"index":29,"letter":"Ь","value":3},{"index":30,"letter":"Э","value":8},{"index":31,"letter":"Ю","value":8},{"index":32,"letter":"Я","value":3}],"labels":{"pass":"пас","exchange":"обмен","resign":"сдался","timeout":"таймаут"},"dateLocale":"ru","hostname":"erudit-game.ru"} \ No newline at end of file diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts index fae1798..54eb524 100644 --- a/ui/e2e/export.spec.ts +++ b/ui/e2e/export.spec.ts @@ -1,18 +1,31 @@ +import { Buffer } from 'node:buffer'; import { expect, test, type Page } from './fixtures'; -// The finished-game export: the history header's 📤 button opens the app's own format -// chooser modal — never Telegram's native popup, whose callback runs without user -// activation and so kills navigator.share / clipboard writes (verified on-device). -// The PNG option is offered only outside the in-app WebViews (no working binary -// delivery there until the server-rendered signed-URL path lands); the GCG file is -// always offered. g3 ("vs Rick") is the seeded finished game. Web Share is -// force-disabled per test so both engines deterministically exercise the download -// branch. +// The finished-game export: one signed relative URL (game.export_url) resolved against +// the app's own origin, delivered by the most native affordance per platform — Telegram's +// showPopup chooser + downloadFile dialog (all bridge calls, activation-safe), VK's +// native viewer/download, the OS share sheet with the fetched file on a mobile browser, +// a plain anchor download on desktop. g3 ("vs Rick") is the seeded finished game. +// The mock gateway returns a shape-faithful /dl/... path; the tests intercept that +// route and serve bytes, so the download itself is exercised end to end. -async function disableWebShare(page: Page): Promise { - await page.addInitScript(() => { - Object.defineProperty(navigator, 'canShare', { value: () => false, configurable: true }); - Object.defineProperty(navigator, 'share', { value: undefined, configurable: true }); +// A minimal valid 1×1 PNG, hex-decoded without node typings (the e2e tsconfig has none). +const PNG_HEX = + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' + + '0000000a49444154789c6360000000020001e221bc330000000049454e44ae426082'; +// Buffer, not Uint8Array: route.fulfill silently hangs a FETCH interception on a +// Uint8Array body (a navigation download tolerates it) — the share test deadlocked. +const PNG_BYTES = Buffer.from(PNG_HEX, 'hex'); + +async function routeDl(page: Page): Promise { + await page.route('**/dl/**', (route) => { + const png = route.request().url().includes('/png'); + return route.fulfill({ + status: 200, + contentType: png ? 'image/png' : 'text/plain; charset=utf-8', + headers: { 'Content-Disposition': 'attachment' }, + body: png ? PNG_BYTES : '#character-encoding UTF-8\n', + }); }); } @@ -25,20 +38,18 @@ async function openFinishedHistory(page: Page): Promise { await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); } -test('the export chooser downloads the PNG image on a plain browser', async ({ page }) => { - await disableWebShare(page); +test('the chooser downloads the PNG through the signed URL on a plain browser', async ({ page }) => { + await routeDl(page); await openFinishedHistory(page); await page.getByRole('button', { name: 'Export game' }).click(); - // The app's own chooser modal offers both formats on the plain web. - await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'Image (PNG)' }).click(); expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/); }); -test('the export chooser downloads the GCG file on a plain browser', async ({ page }) => { - await disableWebShare(page); +test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => { + await routeDl(page); await openFinishedHistory(page); await page.getByRole('button', { name: 'Export game' }).click(); @@ -47,24 +58,29 @@ test('the export chooser downloads the GCG file on a plain browser', async ({ pa expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/); }); -test('inside Telegram the chooser is the app modal and withholds the image option', async ({ +test('inside Telegram the chooser is the native popup and delivery the native downloadFile', async ({ page, }) => { - await disableWebShare(page); - // A Telegram WebApp stub with showPopup present: were the chooser still native, it would - // be called — the spy locks the regression (its activation-less callback breaks share and - // clipboard delivery on real devices). + // A Telegram stub with showPopup AND downloadFile (Bot API 8.0): the whole chain stays + // in bridge calls — the popup picks the image, the artifact goes to the native download + // dialog, never an in-webview anchor. (Activation-safe: no gesture-gated Web APIs.) await page.addInitScript(() => { Object.assign(window, { Telegram: { WebApp: { initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', initDataUnsafe: {}, + platform: 'android', ready() {}, expand() {}, - showPopup(_params: unknown, cb: (id: string) => void) { - (window as unknown as { __popupCalled?: boolean }).__popupCalled = true; - setTimeout(() => cb(''), 0); + showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) { + const w = window as unknown as { __popup?: unknown }; + w.__popup = params; + setTimeout(() => cb('png'), 0); + }, + downloadFile(params: { url: string; file_name: string }) { + const w = window as unknown as { __downloads?: { url: string; file_name: string }[] }; + (w.__downloads ??= []).push(params); }, }, }, @@ -77,11 +93,139 @@ test('inside Telegram the chooser is the app modal and withholds the image optio await page.locator('.scoreboard').click(); await page.getByRole('button', { name: 'Export game' }).click(); - // The app's own modal opened with the GCG option only — no PNG in an in-app WebView… + // The native popup carried both formats plus cancel, and no in-app modal was mounted. + const popup = await page.evaluate( + () => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup, + ); + expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['png', 'gcg', 'cancel']); + await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0); + + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __downloads?: { url: string; file_name: string }[] }).__downloads), + ) + .toHaveLength(1); + const dl = await page.evaluate( + () => (window as unknown as { __downloads: { url: string; file_name: string }[] }).__downloads[0], + ); + // The relative signed path was resolved against the app's own origin — absolute for TG. + expect(dl.url).toMatch(/^http.+\/dl\/.+\/png\?/); + expect(dl.file_name).toMatch(/^game-.+\.png$/); +}); + +test('a mobile browser gets the OS share sheet with the fetched file', async ({ page }) => { + // Web Share with files present (a mobile browser): the artifact is fetched from the + // signed URL and handed to navigator.share as a File — the sheet opens directly, + // nothing lands in Downloads first. + await page.addInitScript(() => { + const shared: { name: string; type: string }[] = []; + Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }); + Object.defineProperty(navigator, 'share', { + value: async (data: { files?: File[] }) => { + for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type }); + }, + configurable: true, + }); + (window as unknown as { __shared: typeof shared }).__shared = shared; + }); + await routeDl(page); + await openFinishedHistory(page); + await page.getByRole('button', { name: 'Export game' }).click(); + await page.getByRole('button', { name: 'Image (PNG)' }).click(); + + await expect + .poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared)) + .toHaveLength(1); + const shared = await page.evaluate( + () => (window as unknown as { __shared: { name: string; type: string }[] }).__shared[0], + ); + expect(shared.name).toMatch(/^game-.+\.png$/); + expect(shared.type).toBe('image/png'); +}); + +test('Telegram on iOS keeps the app chooser and opens the OS share sheet', async ({ page }) => { + // TG iOS: WKWebView has navigator.share, and the sheet is the preferred delivery — but + // it needs the user activation a native-popup callback cannot provide, so the chooser + // stays the app modal on this one platform. + await page.addInitScript(() => { + const shared: { name: string; type: string }[] = []; + Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }); + Object.defineProperty(navigator, 'share', { + value: async (data: { files?: File[] }) => { + for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type }); + }, + configurable: true, + }); + (window as unknown as { __shared: typeof shared }).__shared = shared; + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + platform: 'ios', + ready() {}, + expand() {}, + showPopup() { + (window as unknown as { __popupCalled?: boolean }).__popupCalled = true; + }, + downloadFile() { + (window as unknown as { __downloadCalled?: boolean }).__downloadCalled = true; + }, + }, + }, + }); + }); + await routeDl(page); + await page.goto('/'); + await expect(page.getByText('Your turn')).toBeVisible(); + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await page.getByRole('button', { name: 'Export game' }).click(); + + // The app modal opened (both formats) — the native popup was not used… + await expect(page.getByRole('button', { name: 'Image (PNG)' })).toBeVisible(); + await page.getByRole('button', { name: 'Image (PNG)' }).click(); + // …and the artifact went to the OS share sheet, not TG's download dialog. + await expect + .poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared)) + .toHaveLength(1); + const flags = await page.evaluate(() => { + const w = window as unknown as { __popupCalled?: boolean; __downloadCalled?: boolean }; + return { popup: !!w.__popupCalled, download: !!w.__downloadCalled }; + }); + expect(flags).toEqual({ popup: false, download: false }); +}); + +test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => { + await page.addInitScript(() => { + // Headless engines deny the real clipboard; a permissive stub keeps the legacy + // copy path deterministic in both browsers. + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: async () => {} }, + configurable: true, + }); + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + ready() {}, + expand() {}, + }, + }, + }); + }); + await page.goto('/'); + await expect(page.getByText('Your turn')).toBeVisible(); + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await page.getByRole('button', { name: 'Export game' }).click(); + + // No image option without the native download; the GCG stays available (clipboard path). await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0); - // …and the native popup was never used for the chooser. - expect( - await page.evaluate(() => (window as unknown as { __popupCalled?: boolean }).__popupCalled), - ).toBeFalsy(); + await page.getByRole('button', { name: 'GCG file' }).click(); + await expect(page.getByText('GCG copied to the clipboard.')).toBeVisible(); }); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 7ae6d9e..2268457 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -22,12 +22,12 @@ import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft } from '../lib/hints'; - import { shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share'; - import { insideVK, vkCopyText } from '../lib/vk'; + import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share'; + import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; - import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram'; + import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramPlatform, telegramShowConfirm, telegramShowPopup } from '../lib/telegram'; import { haptic } from '../lib/haptics'; import { BLANK, @@ -931,26 +931,51 @@ return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); } - // The finished-game export offers two formats — the GCG file and the rendered PNG image — - // behind one 📤 button, always through the app's own modal. Never Telegram's native popup - // here: its callback runs with NO user activation, so navigator.share / clipboard writes - // silently fail from it (verified on-device, TG iOS + Android). A real DOM button click in - // the modal keeps the gesture alive for the delivery APIs. + // The finished-game export offers two formats — the GCG file and the server-rendered PNG + // image — behind one 📤 button, both minted as one signed relative URL and delivered by + // the best affordance each platform actually has (every branch owner-verified on-device): + // TG Android/desktop native showPopup chooser → native downloadFile dialog (whose own + // preview shares onwards). All bridge calls — activation-safe. + // TG iOS our modal chooser → the OS share sheet (WKWebView has + // navigator.share; a native-popup callback could not open it — no + // user activation — so the chooser stays the app modal here). + // VK iOS our modal (VK has no native chooser) → VKWebAppDownloadFile for + // both formats — the client lands in its native share/preview flow. + // VK Android our modal → the PNG opens in VK's native image viewer (its save + // works; the Android DownloadFile hangs), the GCG copies to the + // clipboard (the pre-URL route, always solid there). + // VK desktop iframe our modal → plain anchor downloads (an ordinary browser). + // mobile browsers our modal → the OS share sheet with the fetched file. + // desktop browsers our modal → plain anchor download. let exportOpen = $state(false); - // The PNG option is withheld in an in-app WebView (Telegram / VK) for now: a binary image - // has no working delivery there (no Web Share, a dead , text-only clipboard, - // and the long-press menu mangles data: URLs). It returns with the server-rendered - // signed-URL delivery (Telegram downloadFile / VKWebAppDownloadFile). - const exportImageAvailable = $derived(!(insideTelegram() || insideVK())); + // TG iOS shares via the OS sheet regardless of Bot API 8.0; elsewhere in Telegram a + // legacy client without downloadFile keeps the old GCG clipboard copy and hides the + // image option (and the chooser stays our modal). + const tgShareSheet = $derived(insideTelegram() && telegramPlatform() === 'ios'); + const exportImageAvailable = $derived(!insideTelegram() || tgShareSheet || telegramCanDownloadFile()); - function onExportClick() { + async function onExportClick() { + if (insideTelegram() && !tgShareSheet && telegramCanDownloadFile() && telegramDialogsAvailable()) { + const choice = await telegramShowPopup({ + message: t('game.exportChoice'), + buttons: [ + { id: 'png', type: 'default', text: t('game.exportImageOpt') }, + { id: 'gcg', type: 'default', text: t('game.exportGcgOpt') }, + { type: 'cancel' }, + ], + }); + if (choice === 'png' || choice === 'gcg') void exportArtifact(choice); + return; + } exportOpen = true; } - async function exportGcg() { + // Legacy-Telegram GCG delivery (no downloadFile): fetch the text and copy it to the + // clipboard, as before the unified URL route. + async function exportGcgLegacy() { try { const gcg = await gateway.exportGcg(id); - const outcome = await shareOrDownloadGcg(gcg, insideTelegram() || insideVK(), copyGcgText); + const outcome = await shareOrDownloadGcg(gcg, true, copyGcgText); if (outcome === 'copied') showToast(t('game.gcgCopied'), 'info'); else if (outcome === 'failed') showToast(t('error.generic'), 'error'); } catch (e) { @@ -958,17 +983,45 @@ } } - // exportImage renders the finished game as a PNG (lib/gameimage, dynamically imported so the - // renderer stays out of the startup bundle) and delivers it — Web Share where the platform - // supports it, else a download (only reachable outside the in-app WebViews, see - // exportImageAvailable). - async function exportImage() { - if (!view) return; + // exportArtifact delivers a finished game's artifact by the unified signed-URL route. + // The device date locale, its IANA time zone and the UI-localized non-play labels ride + // the URL so the server-rendered image matches what the player would have seen locally. + const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const; + async function exportArtifact(kind: 'png' | 'gcg') { + if (insideTelegram() && !tgShareSheet && !telegramCanDownloadFile()) { + void exportGcgLegacy(); + return; + } try { - const { renderGameImage } = await import('../lib/gameimage'); - const blob = await renderGameImage(view.game, moves, { actionLabel: moveActionLabel }); - const file = new File([blob], `game-${id.slice(0, 8)}.png`, { type: 'image/png' }); - await shareOrDownloadImage(file); + const labels = EXPORT_ACTIONS.map((a) => t(`move.${a}` as MessageKey)); + const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : ''; + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''; + const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone); + const url = new URL(path, location.origin).href; + const mime = kind === 'png' ? 'image/png' : 'text/plain'; + if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return; + if (insideVK()) { + // Per-VK-client delivery (each branch owner-verified): the iOS client's + // VKWebAppDownloadFile lands in a native share flow and is perfect; the ANDROID + // client's download hangs indefinitely, so the PNG opens in VK's native image + // viewer (its "save" works) and the GCG goes to the clipboard; the desktop + // iframe is an ordinary browser — plain anchor downloads. + if (vkAndroidWebView()) { + if (kind === 'png' && (await vkShowImages(url))) return; + if (kind === 'gcg') { + void exportGcgLegacy(); + return; + } + } else if (!vkPlatform().startsWith('desktop') && (await vkDownloadFile(url, filename))) { + return; + } + downloadUrl(url, filename); + return; + } + // TG iOS and plain browsers: the OS share sheet with the fetched file (falls back to + // a download where files cannot be shared — the desktop browser). + const outcome = await shareUrlAsFile(url, filename, mime); + if (outcome === 'failed') showToast(t('error.generic'), 'error'); } catch (e) { handleError(e); } @@ -1492,13 +1545,17 @@ (exportOpen = false)}>
{#if exportImageAvailable} - {/if}