feat(export): server-rendered artifacts behind one signed download URL
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The finished-game export now works identically on every platform: the client mints a signed relative URL (game.export_url) carrying its date locale, IANA time zone and localized non-play labels, resolves it against its own origin and hands it to the platform's native download — Telegram downloadFile, VKWebAppDownloadFile, or a plain browser anchor (also the desktop VK iframe). Both artifacts ride the route: the .gcg text (no more clipboard mode, except the legacy pre-8.0 Telegram fallback) and the PNG of the final position. The PNG is rasterized by a new internal 'renderer' sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the web project unit-tests, bundled at image build time — one renderer, no drift; the browser no longer draws or delivers bytes itself. The backend rebuilds the render payload from the journal + engine.AlphabetTable, verifies the HMAC (10-minute TTL, BACKEND_EXPORT_SIGN_KEY, constant-time, uniform 404s) on its public group, and streams the artifact as a named attachment; the gateway forwards /dl/* behind the per-IP public rate limiter (caddy @gateway matcher extended — the landing catch-all trap). Deploy: renderer service (compose + prod overlay + roll before backend + prod push list), EXPORT_SIGN_KEY env (TEST_/PROD_ secrets), CI runs the sidecar smoke in the ui job. Docs: ARCHITECTURE export-delivery section, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README.
This commit is contained in:
@@ -73,6 +73,9 @@ jobs:
|
|||||||
go=false; ui=false
|
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 '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
||||||
if echo "$files" | grep -qE '^ui/'; then ui=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.
|
# 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
|
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
||||||
else
|
else
|
||||||
@@ -199,6 +202,14 @@ jobs:
|
|||||||
- name: Bundle-size budget
|
- name: Bundle-size budget
|
||||||
run: node scripts/bundle-size.mjs
|
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
|
- name: Install Playwright browsers
|
||||||
run: pnpm exec playwright install chromium webkit
|
run: pnpm exec playwright install chromium webkit
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -319,6 +330,8 @@ jobs:
|
|||||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
# 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.
|
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_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 }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ jobs:
|
|||||||
working-directory: deploy
|
working-directory: deploy
|
||||||
run: |
|
run: |
|
||||||
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
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.
|
# 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 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 build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
|
||||||
docker push "$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 }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
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_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
@@ -139,6 +141,7 @@ jobs:
|
|||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
|
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||||
EOF
|
EOF
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ gateway/ # module scrabble/gateway: Connect-RPC edge, embeds
|
|||||||
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
||||||
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
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)
|
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
|
loadtest/ # module scrabble/loadtest: the load/stress harness
|
||||||
docs/ .gitea/workflows/ CLAUDE.md README.md
|
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 # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
"scrabble/backend/internal/session"
|
"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.
|
// block and the banner admin console section.
|
||||||
adsSvc := ads.NewService(ads.NewStore(db))
|
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{
|
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -253,6 +261,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
BanView: banView,
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ type Config struct {
|
|||||||
// GuestRetention is the account age past which an unused guest (no game seat)
|
// GuestRetention is the account age past which an unused guest (no game seat)
|
||||||
// is eligible for deletion by the reaper.
|
// is eligible for deletion by the reaper.
|
||||||
GuestRetention time.Duration
|
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.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
@@ -151,6 +157,8 @@ func Load() (Config, error) {
|
|||||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||||
GuestReapInterval: guestReapInterval,
|
GuestReapInterval: guestReapInterval,
|
||||||
GuestRetention: guestRetention,
|
GuestRetention: guestRetention,
|
||||||
|
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||||
|
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
|
|||||||
@@ -1363,30 +1363,53 @@ func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDr
|
|||||||
return svc.store.SetupDraws(ctx, gameID)
|
return svc.store.SetupDraws(ctx, gameID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
|
// ExportView returns a finished game with its journal and per-seat display names —
|
||||||
// is allowed only on a finished game: exporting an in-progress game would leak the
|
// the material every export artifact (the GCG text, the PNG render payload) is built
|
||||||
// full move journal mid-play, so an active game yields ErrGameActive.
|
// from. It is allowed only on a finished game: exporting an in-progress game would
|
||||||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
// 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)
|
g, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return Game{}, nil, nil, err
|
||||||
}
|
}
|
||||||
if g.Status != StatusFinished {
|
if g.Status != StatusFinished {
|
||||||
return "", ErrGameActive
|
return Game{}, nil, nil, ErrGameActive
|
||||||
}
|
}
|
||||||
moves, err := svc.store.GetJournal(ctx, gameID)
|
moves, err := svc.store.GetJournal(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return Game{}, nil, nil, err
|
||||||
}
|
}
|
||||||
names := svc.seatNames(ctx, g)
|
names := svc.seatNames(ctx, g)
|
||||||
if g.VsAI {
|
if g.VsAI {
|
||||||
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
|
|
||||||
for _, s := range g.Seats {
|
for _, s := range g.Seats {
|
||||||
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
|
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
|
||||||
names[s.Seat] = aiPlayerName
|
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
|
return writeGCG(g, names, moves), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Package render is the backend's client for the internal image-render sidecar: it POSTs
|
||||||
|
// a finished game's export payload and receives the rasterized PNG. The sidecar runs the
|
||||||
|
// same drawing code the web client unit-tests (ui/src/lib/gameimage.ts on skia-canvas);
|
||||||
|
// authentication and the signed public URL live in the server layer — this client only
|
||||||
|
// carries bytes.
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxPNG caps a render response; a scale-2 export of the largest plausible game is well
|
||||||
|
// under 2 MiB, so anything bigger is a sidecar malfunction, not a valid image.
|
||||||
|
const maxPNG = 8 << 20
|
||||||
|
|
||||||
|
// Client calls the render sidecar over the internal network.
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a client for the sidecar at baseURL (e.g. http://renderer:8090). A render
|
||||||
|
// of a full game takes well under a second; the timeout leaves room for a cold start.
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
return &Client{base: baseURL, http: &http.Client{Timeout: 15 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render posts payload (the JSON request shape the sidecar consumes) and returns the PNG.
|
||||||
|
func (c *Client) Render(ctx context.Context, payload any) ([]byte, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/render", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("render: sidecar status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
png, err := io.ReadAll(io.LimitReader(resp.Body, maxPNG+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: read response: %w", err)
|
||||||
|
}
|
||||||
|
if len(png) > maxPNG {
|
||||||
|
return nil, fmt.Errorf("render: response exceeds %d bytes", maxPNG)
|
||||||
|
}
|
||||||
|
return png, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// Finished-game export downloads: a signed, short-lived, relative URL minted for a
|
||||||
|
// participant and later fetched with no credentials at all — the platforms' native
|
||||||
|
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain browser
|
||||||
|
// anchor) strip cookies and headers, so the HMAC in the URL is the whole grant.
|
||||||
|
// The client resolves the relative path against its own origin; the edge routes
|
||||||
|
// /dl/* to the gateway, which forwards it here (/api/v1/public/dl/...).
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportURLTTL is how long a minted download URL stays valid. Long enough for the
|
||||||
|
// platform's download dialog and a retry, short enough that a leaked link dies fast.
|
||||||
|
const exportURLTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// Query/label size caps: the presentation params ride the signed URL, so they are
|
||||||
|
// bounded defensively (they only ever hold a BCP-47 tag and four short UI words).
|
||||||
|
const (
|
||||||
|
maxDateLocaleLen = 35
|
||||||
|
maxLabelsLen = 120
|
||||||
|
maxTimeZoneLen = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportActionOrder fixes the position of each non-play move label in the `t` CSV.
|
||||||
|
var exportActionOrder = [4]string{"pass", "exchange", "resign", "timeout"}
|
||||||
|
|
||||||
|
// signExport computes the URL signature over the canonical parameter string. The
|
||||||
|
// canonical form is independent of the public path prefix, so the gateway may mount
|
||||||
|
// the route anywhere.
|
||||||
|
func (s *Server) signExport(gameID, kind, exp, dateLocale, timeZone, labels string) string {
|
||||||
|
mac := hmac.New(sha256.New, s.exportKey)
|
||||||
|
fmt.Fprintf(mac, "%s|%s|%s|%s|%s|%s", gameID, kind, exp, dateLocale, timeZone, labels)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportURLDTO is the minted link: a relative signed path plus the download filename.
|
||||||
|
type exportURLDTO struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExportURL mints the signed download path for a finished game's artifact.
|
||||||
|
// GET /api/v1/user/games/:id/export-url?kind=png|gcg&dl=<date-locale>&tz=<IANA zone>&t=<labels CSV>
|
||||||
|
func (s *Server) handleExportURL(c *gin.Context) {
|
||||||
|
if len(s.exportKey) == 0 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable,
|
||||||
|
errorResponse{Error: errorBody{Code: "export_unavailable", Message: "export signing is not configured"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, gameID, ok := s.userGame(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind := c.Query("kind")
|
||||||
|
if kind != "png" && kind != "gcg" {
|
||||||
|
abortBadRequest(c, "invalid kind")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dateLocale := c.Query("dl")
|
||||||
|
timeZone := c.Query("tz")
|
||||||
|
labels := c.Query("t")
|
||||||
|
if len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
|
||||||
|
abortBadRequest(c, "presentation params too long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.games.EnsureExportable(c.Request.Context(), gameID); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exp := strconv.FormatInt(time.Now().Add(exportURLTTL).Unix(), 10)
|
||||||
|
sig := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
|
||||||
|
q := url.Values{"e": {exp}, "s": {sig}}
|
||||||
|
if dateLocale != "" {
|
||||||
|
q.Set("l", dateLocale)
|
||||||
|
}
|
||||||
|
if timeZone != "" {
|
||||||
|
q.Set("z", timeZone)
|
||||||
|
}
|
||||||
|
if labels != "" {
|
||||||
|
q.Set("t", labels)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, exportURLDTO{
|
||||||
|
Path: "/dl/" + gameID.String() + "/" + kind + "?" + q.Encode(),
|
||||||
|
Filename: exportFilename(gameID, kind),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportFilename(gameID uuid.UUID, kind string) string {
|
||||||
|
return "game-" + gameID.String()[:8] + "." + kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExportDownload serves a minted artifact. It is a public route: the signature
|
||||||
|
// and expiry are the only authorization (see the package comment). Every failure is
|
||||||
|
// a plain 404 so the endpoint is not a validity oracle.
|
||||||
|
// GET /api/v1/public/dl/:id/:kind?e=&l=&z=&t=&s=
|
||||||
|
func (s *Server) handleExportDownload(c *gin.Context) {
|
||||||
|
if len(s.exportKey) == 0 {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, err := uuid.Parse(c.Param("id"))
|
||||||
|
kind := c.Param("kind")
|
||||||
|
exp := c.Query("e")
|
||||||
|
dateLocale := c.Query("l")
|
||||||
|
timeZone := c.Query("z")
|
||||||
|
labels := c.Query("t")
|
||||||
|
sig := c.Query("s")
|
||||||
|
if err != nil || (kind != "png" && kind != "gcg") ||
|
||||||
|
len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
|
||||||
|
if subtleNeq(sig, want) {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if unix, err := strconv.ParseInt(exp, 10, 64); err != nil || time.Now().Unix() > unix {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := exportFilename(gameID, kind)
|
||||||
|
if kind == "gcg" {
|
||||||
|
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveAttachment(c, filename, "text/plain; charset=utf-8", []byte(gcg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.renderer == nil {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g, moves, names, err := s.games.ExportView(c.Request.Context(), gameID)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := renderPayload(g, moves, names, dateLocale, timeZone, labels, c.GetHeader("X-Public-Host"))
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("export render payload", zap.Error(err))
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
png, err := s.renderer.Render(c.Request.Context(), payload)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("export render", zap.Error(err))
|
||||||
|
c.String(http.StatusServiceUnavailable, "render unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveAttachment(c, filename, "image/png", png)
|
||||||
|
}
|
||||||
|
|
||||||
|
// subtleNeq compares two signature strings in constant time.
|
||||||
|
func subtleNeq(got, want string) bool {
|
||||||
|
return !hmac.Equal([]byte(got), []byte(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveAttachment writes bytes as a named file download.
|
||||||
|
func serveAttachment(c *gin.Context, filename, contentType string, data []byte) {
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(filename)+`"`)
|
||||||
|
c.Header("Cache-Control", "private, max-age=0")
|
||||||
|
c.Data(http.StatusOK, contentType, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the render-sidecar request payload (the ui-model shapes drawGameImage consumes) ---
|
||||||
|
|
||||||
|
type renderSeatJSON struct {
|
||||||
|
Seat int `json:"seat"`
|
||||||
|
AccountID string `json:"accountId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
HintsUsed int `json:"hintsUsed"`
|
||||||
|
IsWinner bool `json:"isWinner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderGameJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Players int `json:"players"`
|
||||||
|
LastActivityUnix int64 `json:"lastActivityUnix"`
|
||||||
|
Seats []renderSeatJSON `json:"seats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderTileJSON struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Blank bool `json:"blank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderMoveJSON struct {
|
||||||
|
Player int `json:"player"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
MainRow int `json:"mainRow"`
|
||||||
|
MainCol int `json:"mainCol"`
|
||||||
|
Tiles []renderTileJSON `json:"tiles"`
|
||||||
|
Words []string `json:"words"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderAlphaJSON struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderRequestJSON struct {
|
||||||
|
Game renderGameJSON `json:"game"`
|
||||||
|
Moves []renderMoveJSON `json:"moves"`
|
||||||
|
Alphabet []renderAlphaJSON `json:"alphabet"`
|
||||||
|
Labels map[string]string `json:"labels"`
|
||||||
|
DateLocale string `json:"dateLocale"`
|
||||||
|
TimeZone string `json:"timeZone"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPayload assembles the sidecar request from the export view. Letters are
|
||||||
|
// upper-cased for display exactly as the client codec does; the localized non-play
|
||||||
|
// labels arrive as a positional CSV (see exportActionOrder) minted into the URL.
|
||||||
|
func renderPayload(g game.Game, moves []game.HistoryMove, names []string, dateLocale, timeZone, labelsCSV, host string) (renderRequestJSON, error) {
|
||||||
|
alpha, err := engine.AlphabetTable(g.Variant)
|
||||||
|
if err != nil {
|
||||||
|
return renderRequestJSON{}, fmt.Errorf("alphabet for %s: %w", g.Variant, err)
|
||||||
|
}
|
||||||
|
req := renderRequestJSON{
|
||||||
|
Game: renderGameJSON{
|
||||||
|
ID: g.ID.String(),
|
||||||
|
Variant: g.Variant.String(),
|
||||||
|
Status: g.Status,
|
||||||
|
Players: g.Players,
|
||||||
|
},
|
||||||
|
Labels: map[string]string{},
|
||||||
|
DateLocale: dateLocale,
|
||||||
|
TimeZone: timeZone,
|
||||||
|
Hostname: host,
|
||||||
|
}
|
||||||
|
finished := g.UpdatedAt
|
||||||
|
if g.FinishedAt != nil {
|
||||||
|
finished = *g.FinishedAt
|
||||||
|
}
|
||||||
|
req.Game.LastActivityUnix = finished.Unix()
|
||||||
|
for _, st := range g.Seats {
|
||||||
|
name := st.DisplayName
|
||||||
|
if st.Seat < len(names) {
|
||||||
|
name = names[st.Seat]
|
||||||
|
}
|
||||||
|
req.Game.Seats = append(req.Game.Seats, renderSeatJSON{
|
||||||
|
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: name,
|
||||||
|
Score: st.Score, HintsUsed: st.HintsUsed, IsWinner: st.IsWinner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, m := range moves {
|
||||||
|
mv := renderMoveJSON{
|
||||||
|
Player: m.Seat, Action: m.Action, Dir: m.Dir,
|
||||||
|
MainRow: m.MainRow, MainCol: m.MainCol,
|
||||||
|
Words: make([]string, 0, len(m.Words)),
|
||||||
|
Count: len(m.Exchanged), Score: m.Score, Total: m.RunningTotal,
|
||||||
|
Tiles: make([]renderTileJSON, 0, len(m.Tiles)),
|
||||||
|
}
|
||||||
|
for _, w := range m.Words {
|
||||||
|
mv.Words = append(mv.Words, strings.ToUpper(w))
|
||||||
|
}
|
||||||
|
for _, t := range m.Tiles {
|
||||||
|
mv.Tiles = append(mv.Tiles, renderTileJSON{Row: t.Row, Col: t.Col, Letter: strings.ToUpper(t.Letter), Blank: t.Blank})
|
||||||
|
}
|
||||||
|
req.Moves = append(req.Moves, mv)
|
||||||
|
}
|
||||||
|
for _, a := range alpha {
|
||||||
|
req.Alphabet = append(req.Alphabet, renderAlphaJSON{Index: int(a.Index), Letter: strings.ToUpper(a.Letter), Value: a.Value})
|
||||||
|
}
|
||||||
|
if labelsCSV != "" {
|
||||||
|
parts := strings.Split(labelsCSV, ",")
|
||||||
|
for i, action := range exportActionOrder {
|
||||||
|
if i < len(parts) && parts[i] != "" {
|
||||||
|
req.Labels[action] = parts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newExportServer is the routing harness with a signing key installed, so the
|
||||||
|
// pre-service branches of the export endpoints are reachable.
|
||||||
|
func newExportServer() *Server {
|
||||||
|
return New(":0", Deps{
|
||||||
|
Sessions: &session.Service{},
|
||||||
|
Accounts: &account.Store{},
|
||||||
|
Games: &game.Service{},
|
||||||
|
ExportSignKey: "test-key",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignExportIsDeterministicAndTamperEvident(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
sig := s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен")
|
||||||
|
if sig != s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен") {
|
||||||
|
t.Fatal("same inputs produced different signatures")
|
||||||
|
}
|
||||||
|
for _, tampered := range []string{
|
||||||
|
s.signExport("g2", "png", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "gcg", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "124", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "en", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "ru", "UTC", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас"),
|
||||||
|
} {
|
||||||
|
if tampered == sig {
|
||||||
|
t.Fatal("tampered input produced the same signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportURLWithoutKeyIs503(t *testing.T) {
|
||||||
|
s := New(":0", Deps{Sessions: &session.Service{}, Accounts: &account.Store{}, Games: &game.Service{}})
|
||||||
|
rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+uuid.NewString()+"/export-url?kind=png", "",
|
||||||
|
map[string]string{"X-User-ID": uuid.NewString()})
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("mint without key = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportURLRejectsBadParams(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
uid := map[string]string{"X-User-ID": uuid.NewString()}
|
||||||
|
gid := uuid.NewString()
|
||||||
|
|
||||||
|
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=exe", "", uid); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad kind = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
long := strings.Repeat("x", maxLabelsLen+1)
|
||||||
|
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=png&t="+long, "", uid); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("oversized labels = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportDownloadRejectsBadSignatures(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
gid := uuid.NewString()
|
||||||
|
exp := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)
|
||||||
|
|
||||||
|
// A wrong signature never reaches the domain (404 before any service call).
|
||||||
|
url := "/api/v1/public/dl/" + gid + "/png?e=" + exp + "&s=forged"
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("forged signature = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid signature over an EXPIRED timestamp is refused the same way.
|
||||||
|
old := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)
|
||||||
|
url = "/api/v1/public/dl/" + gid + "/png?e=" + old + "&s=" + s.signExport(gid, "png", old, "", "", "")
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expired link = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown kind is refused before signature checks.
|
||||||
|
url = "/api/v1/public/dl/" + gid + "/exe?e=" + exp + "&s=x"
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("bad kind = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPayloadMapsTheExportView(t *testing.T) {
|
||||||
|
gid := uuid.New()
|
||||||
|
finished := time.Unix(1_782_997_629, 0)
|
||||||
|
g := game.Game{
|
||||||
|
ID: gid,
|
||||||
|
Variant: engine.VariantRussianScrabble,
|
||||||
|
Status: game.StatusFinished,
|
||||||
|
Players: 2,
|
||||||
|
FinishedAt: &finished,
|
||||||
|
Seats: []game.Seat{
|
||||||
|
{Seat: 0, Score: 42, IsWinner: true, DisplayName: "snapshot"},
|
||||||
|
{Seat: 1, Score: 30},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
moves := []game.HistoryMove{
|
||||||
|
{
|
||||||
|
Seat: 0, Action: "play", Dir: "H", MainRow: 7, MainCol: 4,
|
||||||
|
Tiles: []engine.TileRecord{{Row: 7, Col: 4, Letter: "ч", Blank: false}},
|
||||||
|
Words: []string{"чика"}, Score: 9, RunningTotal: 9,
|
||||||
|
},
|
||||||
|
{Seat: 1, Action: "exchange", Exchanged: []string{"а", "б"}, RunningTotal: 0},
|
||||||
|
}
|
||||||
|
names := []string{"Аня", "Боб"}
|
||||||
|
|
||||||
|
p, err := renderPayload(g, moves, names, "ru", "Europe/Moscow", "пас,обмен,сдался,таймаут", "erudit-game.ru")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderPayload: %v", err)
|
||||||
|
}
|
||||||
|
if p.Game.LastActivityUnix != finished.Unix() {
|
||||||
|
t.Fatalf("lastActivityUnix = %d, want %d", p.Game.LastActivityUnix, finished.Unix())
|
||||||
|
}
|
||||||
|
if p.Game.Seats[0].DisplayName != "Аня" || !p.Game.Seats[0].IsWinner {
|
||||||
|
t.Fatalf("seat 0 = %+v, want the resolved name and the winner flag", p.Game.Seats[0])
|
||||||
|
}
|
||||||
|
if p.Moves[0].Words[0] != "ЧИКА" || p.Moves[0].Tiles[0].Letter != "Ч" {
|
||||||
|
t.Fatalf("letters not upper-cased: %+v", p.Moves[0])
|
||||||
|
}
|
||||||
|
if p.Moves[1].Count != 2 {
|
||||||
|
t.Fatalf("exchange count = %d, want 2", p.Moves[1].Count)
|
||||||
|
}
|
||||||
|
if p.Labels["pass"] != "пас" || p.Labels["timeout"] != "таймаут" {
|
||||||
|
t.Fatalf("labels not mapped positionally: %v", p.Labels)
|
||||||
|
}
|
||||||
|
if len(p.Alphabet) == 0 || p.Hostname != "erudit-game.ru" || p.TimeZone != "Europe/Moscow" {
|
||||||
|
t.Fatalf("alphabet/hostname/zone missing: %d entries, host %q, tz %q", len(p.Alphabet), p.Hostname, p.TimeZone)
|
||||||
|
}
|
||||||
|
for _, a := range p.Alphabet {
|
||||||
|
if a.Letter != strings.ToUpper(a.Letter) {
|
||||||
|
t.Fatalf("alphabet letter %q not upper-cased", a.Letter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,12 +87,17 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||||
u.GET("/games/:id/history", s.handleHistory)
|
u.GET("/games/:id/history", s.handleHistory)
|
||||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||||
|
u.GET("/games/:id/export-url", s.handleExportURL)
|
||||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||||
u.POST("/games/:id/hide", s.handleHideGame)
|
u.POST("/games/:id/hide", s.handleHideGame)
|
||||||
// Raw dictionary download for the client-side local move preview, keyed by
|
// Raw dictionary download for the client-side local move preview, keyed by
|
||||||
// the game's pinned (variant, version); immutable, so cached hard.
|
// the game's pinned (variant, version); immutable, so cached hard.
|
||||||
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
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 {
|
if s.feedback != nil {
|
||||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
@@ -96,6 +97,12 @@ type Deps struct {
|
|||||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||||
// them (notify.Nop).
|
// them (notify.Nop).
|
||||||
Notifier notify.Publisher
|
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
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -124,6 +131,8 @@ type Server struct {
|
|||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
|
exportKey []byte
|
||||||
|
renderer *render.Client
|
||||||
|
|
||||||
public *gin.RouterGroup
|
public *gin.RouterGroup
|
||||||
user *gin.RouterGroup
|
user *gin.RouterGroup
|
||||||
@@ -173,8 +182,12 @@ func New(addr string, deps Deps) *Server {
|
|||||||
banview: deps.BanView,
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
|
renderer: deps.Renderer,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
}
|
}
|
||||||
|
if deps.ExportSignKey != "" {
|
||||||
|
s.exportKey = []byte(deps.ExportSignKey)
|
||||||
|
}
|
||||||
s.registerProbes(engine)
|
s.registerProbes(engine)
|
||||||
s.registerAPIGroups(engine)
|
s.registerAPIGroups(engine)
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ DICT_VERSION=v1.3.0
|
|||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
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 -----------------------------------------------------------
|
# --- Edge / caddy -----------------------------------------------------------
|
||||||
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
# 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.
|
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
||||||
|
|||||||
+5
-2
@@ -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. |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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`). |
|
| `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 '<pw>'`. |
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
|
| `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
|
**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
|
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`),
|
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
|
||||||
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
||||||
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
|
`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) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
|
||||||
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
|
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
|
||||||
deploy), and all services' `*_OTEL_*_EXPORTER=otlp` →
|
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_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
|
||||||
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
`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:
|
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,
|
`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,
|
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
# 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
|
# 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).
|
# 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 {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081 {
|
reverse_proxy gateway:8081 {
|
||||||
header_up -X-Scrabble-Honeypot
|
header_up -X-Scrabble-Honeypot
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ services:
|
|||||||
limits:
|
limits:
|
||||||
memory: 384M
|
memory: 384M
|
||||||
|
|
||||||
|
renderer:
|
||||||
|
image: ${REGISTRY:?set REGISTRY}/scrabble-renderer:${TAG:?set TAG}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 160M
|
||||||
|
|
||||||
validator:
|
validator:
|
||||||
image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG}
|
image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG}
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -61,6 +61,36 @@ services:
|
|||||||
memory: 512M
|
memory: 512M
|
||||||
networks: [internal]
|
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:
|
backend:
|
||||||
container_name: scrabble-backend
|
container_name: scrabble-backend
|
||||||
image: scrabble-backend:latest
|
image: scrabble-backend:latest
|
||||||
@@ -80,9 +110,15 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
renderer:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
# search_path=backend matches the migrations (00001 creates the schema).
|
# 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
|
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
|
# The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom
|
||||||
# for bursts. Postgres (2 cores / 512 MiB) handles it.
|
# for bursts. Postgres (2 cores / 512 MiB) handles it.
|
||||||
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
|
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ fi
|
|||||||
|
|
||||||
# Roll one service at a time, least -> most dependent; any failure rolls everything back.
|
# Roll one service at a time, least -> most dependent; any failure rolls everything back.
|
||||||
roll postgres health_postgres || { rollback; exit 1; }
|
roll postgres health_postgres || { rollback; exit 1; }
|
||||||
|
roll renderer health_running scrabble-renderer || { rollback; exit 1; }
|
||||||
roll backend health_backend || { rollback; exit 1; }
|
roll backend health_backend || { rollback; exit 1; }
|
||||||
roll gateway health_running scrabble-gateway || { rollback; exit 1; }
|
roll gateway health_running scrabble-gateway || { rollback; exit 1; }
|
||||||
roll landing health_landing || { rollback; exit 1; }
|
roll landing health_landing || { rollback; exit 1; }
|
||||||
|
|||||||
+26
-5
@@ -785,11 +785,32 @@ 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`
|
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||||||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||||||
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
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`
|
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
|
||||||
otherwise), so an in-progress journal is never leaked mid-play; the client
|
otherwise), so an in-progress journal is never leaked mid-play.
|
||||||
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 `<a download>`, so there it copies the GCG
|
**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text
|
||||||
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
|
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=<expiry>&…&s=<HMAC-SHA256>`, 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 platform's native
|
||||||
|
download: Telegram `downloadFile` (Bot API 8.0), `VKWebAppDownloadFile`, or a plain
|
||||||
|
browser anchor elsewhere (including the desktop VK iframe). 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 plus its 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 remaining platform branch 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
|
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 —
|
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||||
|
|||||||
+22
-22
@@ -326,29 +326,29 @@ 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
|
modal** — deliberately not Telegram's native popup, whose callback runs without
|
||||||
user activation and silently breaks the share/clipboard delivery it leads to.
|
user activation and silently breaks the share/clipboard delivery it leads to.
|
||||||
|
|
||||||
The **image** is rendered on the client (Canvas 2D, always the light theme): the
|
The **image** is rendered on the server (the internal render sidecar runs the same
|
||||||
final board with classic coordinate axes A..O / 1..15 on the left — premium squares
|
drawing module the web client tests; always the light theme): the final board with
|
||||||
as plain colour fills, no text labels — and a compact per-seat scoresheet on the
|
classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour
|
||||||
right: each seat's name and final score in the header (🏆 by the winner), then one
|
fills, no text labels — and a compact per-seat scoresheet on the right: each seat's
|
||||||
row per move carrying the main word's classic coordinate (an across play is
|
name and final score in the header (🏆 by the winner), then one row per move
|
||||||
row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and
|
carrying the main word's classic coordinate (an across play is row-first, `8G`; a
|
||||||
its points; extra words of a multi-word play ride a smaller second line, non-play
|
down play column-first, `H8` — the GCG convention), the word and its points; extra
|
||||||
moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row
|
words of a multi-word play ride a smaller second line, non-play moves show as
|
||||||
shows the endgame rack settlement when there was one (the final scores are
|
localized notes (pass/exchange/resign/timeout), and a closing ± row shows the
|
||||||
authoritative — running totals alone do not include it). The footer stamps the site
|
endgame rack settlement when there was one (the final scores are authoritative —
|
||||||
host and the finish date in the device locale. The scoresheet typography is fixed;
|
running totals alone do not include it). The footer stamps the site host and the
|
||||||
a long game stretches the board (never below its minimum) so the image carries no
|
finish date in the device locale. The scoresheet typography is fixed; a long game
|
||||||
dead space.
|
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;
|
Delivery is **one route for both formats on every platform**: the app requests a
|
||||||
on an Android in-app client (Telegram / VK), which has neither Web Share nor a
|
signed, short-lived download link and hands it to the platform's native download —
|
||||||
working file download, it copies the GCG text to the clipboard (with a confirming
|
Telegram's download dialog, VK's `VKWebAppDownloadFile`, or an ordinary browser
|
||||||
toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the
|
file download elsewhere (including the desktop VK iframe). The link needs no login
|
||||||
same way, but a binary image has no clipboard-text fallback at all — so the image
|
to fetch (the platforms' download calls carry none), is valid for minutes, and
|
||||||
option is currently **withheld inside the in-app webviews** (Telegram / VK) and
|
serves the artifact as a named file attachment. The single exception is a legacy
|
||||||
offered on the plain web and mobile browsers only; it returns there with the
|
Telegram client without the download dialog (pre-Bot API 8.0): there the GCG falls
|
||||||
server-rendered signed-URL delivery (Telegram `downloadFile` /
|
back to the old clipboard copy (with the confirming toast) and the image option is
|
||||||
`VKWebAppDownloadFile`). Statistics (durable accounts only):
|
not offered. Statistics (durable accounts only):
|
||||||
wins, losses, draws, max points in a game, and max points for a single move (the
|
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
|
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
|
also shows the player's **move count** (their plays — passes and exchanges do not
|
||||||
|
|||||||
+11
-10
@@ -335,7 +335,8 @@ Telegram.
|
|||||||
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
|
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
|
||||||
молча ломает доставку через share/буфер, к которой ведёт выбор.
|
молча ломает доставку через share/буфер, к которой ведёт выбор.
|
||||||
|
|
||||||
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
|
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
|
||||||
|
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
|
||||||
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
|
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
|
||||||
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
|
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
|
||||||
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
|
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
|
||||||
@@ -348,15 +349,15 @@ Telegram.
|
|||||||
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
|
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
|
||||||
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
|
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
|
||||||
|
|
||||||
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
|
Доставка — **один путь для обоих форматов на любой платформе**: приложение
|
||||||
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
|
запрашивает подписанную короткоживущую ссылку на скачивание и передаёт её родному
|
||||||
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
|
механизму платформы — диалогу загрузки Telegram, `VKWebAppDownloadFile` у VK или
|
||||||
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же,
|
обычному браузерному скачиванию файла в остальных случаях (включая десктопный
|
||||||
но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт
|
iframe VK). Ссылка не требует логина при скачивании (родные загрузчики платформ его
|
||||||
«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается
|
не несут), живёт минуты и отдаёт артефакт именованным файлом. Единственное
|
||||||
только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером
|
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
|
||||||
и доставкой по подписанному URL (Telegram `downloadFile` /
|
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
|
||||||
`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов):
|
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
|
||||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||||
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||||
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||||
|
|||||||
+9
-2
@@ -19,8 +19,15 @@ tests or touching CI.
|
|||||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
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
|
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
|
invitations section, the stats screen, profile editing, and the export chooser's
|
||||||
finished-only visibility.
|
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
|
- **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
|
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
|
authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit
|
||||||
|
|||||||
+23
-22
@@ -407,28 +407,29 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
(the word(s) and the move score, no running total); the 📤 in the history header appears
|
(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
|
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
|
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
|
(a `showPopup` callback carries no user activation, which silently breaks gesture-gated
|
||||||
`navigator.share` / clipboard delivery it leads to — on-device finding). The accent button
|
delivery APIs — on-device finding). The accent button leads with the image, the GCG file
|
||||||
leads with the image where offered, the GCG file is the quiet alternative (GCG needs the
|
is the quiet alternative; both need the connection. Both formats travel the same
|
||||||
connection, the image renders locally). The GCG file goes by Web Share where available, a
|
signed-URL route: the app mints a relative link (`game.export_url`), resolves it against
|
||||||
clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor
|
its own origin and hands it to the platform's native download — Telegram `downloadFile`,
|
||||||
a working download), else a Blob download. The image option shows **only outside the in-app
|
`VKWebAppDownloadFile`, else a plain anchor download (also the desktop VK iframe, where
|
||||||
WebViews** for now (a binary PNG has no working route there — no Web Share, a dead
|
the bridge method is absent and the anchor works). A legacy Telegram client without
|
||||||
`<a download>`, a text-only clipboard, and the long-press menu mangles data: URLs); it
|
`downloadFile` keeps the old GCG clipboard copy and hides the image option. Confirming a
|
||||||
returns there with the server-rendered signed-URL delivery. Confirming a resign reveals
|
resign reveals the full board: it closes the history drawer (portrait) and zooms the
|
||||||
the full board: it closes the history drawer (portrait) and zooms the board out.
|
board out.
|
||||||
- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light
|
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
|
||||||
palette (pinned constants mirroring `app.css`), the final board with classic axes A..O /
|
executes; the browser app no longer draws it): always the light palette (pinned constants
|
||||||
1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style
|
mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
|
||||||
(bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move
|
plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left,
|
||||||
highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by
|
value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is
|
||||||
the winner), one fixed-typography row per move — muted classic coordinate (across =
|
the scoresheet: per-seat name over the final score (🏆 by the winner), one
|
||||||
row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra
|
fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down =
|
||||||
words of a multi-word play on a smaller second line; italic localized notes for
|
column-first `H8`), the main word, points right-aligned; extra words of a multi-word play
|
||||||
pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when
|
on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a
|
||||||
present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's
|
closing muted ± row for the endgame rack settlement when present. Footer:
|
||||||
height drives the board side (never below its minimum): a long game stretches the board,
|
`hostname · finish date` in the **device** locale. The scoresheet's height drives the
|
||||||
never the typography, so the image has no dead space.
|
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
|
- **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
|
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
|
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The response structs and client methods mirror the backend's social,
|
// 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)
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/gcg"), userID, "", nil, &out)
|
||||||
return out, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,10 +39,18 @@ const backendMaxIdleConns = 512
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
conn *grpc.ClientConn
|
// dl serves the export downloads: the backend may wait on the render sidecar
|
||||||
push pushv1.PushClient
|
// 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
|
// 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
|
// backend lives on a trusted network segment, so the gRPC connection uses
|
||||||
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
|
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
|
||||||
@@ -62,11 +70,39 @@ func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) {
|
|||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(httpURL, "/"),
|
baseURL: strings.TrimRight(httpURL, "/"),
|
||||||
http: &http.Client{Timeout: timeout, Transport: transport},
|
http: &http.Client{Timeout: timeout, Transport: transport},
|
||||||
|
dl: &http.Client{Timeout: exportDownloadTimeout, Transport: transport},
|
||||||
conn: conn,
|
conn: conn,
|
||||||
push: pushv1.NewPushClient(conn),
|
push: pushv1.NewPushClient(conn),
|
||||||
}, nil
|
}, 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. "/<game>/<kind>?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.
|
// Close releases the gRPC connection.
|
||||||
func (c *Client) Close() error { return c.conn.Close() }
|
func (c *Client) Close() error { return c.conn.Close() }
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
// The client-side local move preview pulls each game's pinned dictionary blob
|
// The client-side local move preview pulls each game's pinned dictionary blob
|
||||||
// through this session-gated route (not public); see dictBytesHandler.
|
// through this session-gated route (not public); see dictBytesHandler.
|
||||||
mux.Handle("/dict/", s.dictBytesHandler())
|
mux.Handle("/dict/", s.dictBytesHandler())
|
||||||
|
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||||
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||||
@@ -415,6 +416,59 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
|
|||||||
// user-class limiter, and forwards the backend's immutable Cache-Control so the
|
// 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
|
// browser caches the blob hard. Only GET is allowed; the path is
|
||||||
// /dict/{variant}/{version}.
|
// /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")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) dictBytesHandler() http.Handler {
|
func (s *Server) dictBytesHandler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.backend == nil {
|
if s.backend == nil {
|
||||||
|
|||||||
@@ -251,6 +251,18 @@ func encodeInvitationList(r backendclient.InvitationListResp) []byte {
|
|||||||
return b.FinishedBytes()
|
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.
|
// encodeGcg builds a GcgExport payload.
|
||||||
func encodeGcg(r backendclient.GcgResp) []byte {
|
func encodeGcg(r backendclient.GcgResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(1024)
|
b := flatbuffers.NewBuilder(1024)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
MsgProfileUpdate = "profile.update"
|
MsgProfileUpdate = "profile.update"
|
||||||
MsgStatsGet = "stats.get"
|
MsgStatsGet = "stats.get"
|
||||||
MsgGameGCG = "game.gcg"
|
MsgGameGCG = "game.gcg"
|
||||||
|
MsgGameExportURL = "game.export_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerSocialOps adds the social, account and history operations to the
|
// 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[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true}
|
||||||
r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true}
|
r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true}
|
r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgGameExportURL] = Op{Handler: exportURLHandler(backend), Auth: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- friends ---
|
// --- 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.
|
// decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest.
|
||||||
func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string {
|
func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string {
|
||||||
n := in.InviteeIdsLength()
|
n := in.InviteeIdsLength()
|
||||||
|
|||||||
@@ -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) {
|
func TestProfileUpdateRoundTripAway(t *testing.T) {
|
||||||
var gotBody map[string]any
|
var gotBody map[string]any
|
||||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -658,6 +658,27 @@ table GcgExport {
|
|||||||
content:string;
|
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 ---
|
// --- push event payloads ---
|
||||||
|
|
||||||
// YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the
|
// YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+366
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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';
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+77
-32
@@ -1,18 +1,31 @@
|
|||||||
import { expect, test, type Page } from './fixtures';
|
import { expect, test, type Page } from './fixtures';
|
||||||
|
|
||||||
// The finished-game export: the history header's 📤 button opens the app's own format
|
// 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
|
// chooser (never Telegram's native popup — its callback carries no user activation),
|
||||||
// activation and so kills navigator.share / clipboard writes (verified on-device).
|
// and BOTH formats travel the same unified route on every platform: the client mints a
|
||||||
// The PNG option is offered only outside the in-app WebViews (no working binary
|
// signed relative URL (game.export_url), resolves it against its own origin and hands
|
||||||
// delivery there until the server-rendered signed-URL path lands); the GCG file is
|
// it to the platform's native download — Telegram downloadFile / VKWebAppDownloadFile —
|
||||||
// always offered. g3 ("vs Rick") is the seeded finished game. Web Share is
|
// or a plain anchor download in a browser. g3 ("vs Rick") is the seeded finished game.
|
||||||
// force-disabled per test so both engines deterministically exercise the download
|
// The mock gateway returns a shape-faithful /dl/... path; the tests intercept that
|
||||||
// branch.
|
// route and serve bytes, so the download itself is exercised end to end.
|
||||||
|
|
||||||
async function disableWebShare(page: Page): Promise<void> {
|
// A minimal valid 1×1 PNG, hex-decoded without node typings (the e2e tsconfig has none).
|
||||||
await page.addInitScript(() => {
|
const PNG_HEX =
|
||||||
Object.defineProperty(navigator, 'canShare', { value: () => false, configurable: true });
|
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' +
|
||||||
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
|
'0000000a49444154789c6360000000020001e221bc330000000049454e44ae426082';
|
||||||
|
const PNG_BYTES = Uint8Array.from({ length: PNG_HEX.length / 2 }, (_, i) =>
|
||||||
|
parseInt(PNG_HEX.slice(i * 2, i * 2 + 2), 16),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function routeDl(page: Page): Promise<void> {
|
||||||
|
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<void> {
|
|||||||
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
test('the export chooser downloads the PNG image on a plain browser', async ({ page }) => {
|
test('the chooser downloads the PNG through the signed URL on a plain browser', async ({ page }) => {
|
||||||
await disableWebShare(page);
|
await routeDl(page);
|
||||||
await openFinishedHistory(page);
|
await openFinishedHistory(page);
|
||||||
await page.getByRole('button', { name: 'Export game' }).click();
|
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');
|
const download = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Image (PNG)' }).click();
|
await page.getByRole('button', { name: 'Image (PNG)' }).click();
|
||||||
expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/);
|
expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the export chooser downloads the GCG file on a plain browser', async ({ page }) => {
|
test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => {
|
||||||
await disableWebShare(page);
|
await routeDl(page);
|
||||||
await openFinishedHistory(page);
|
await openFinishedHistory(page);
|
||||||
await page.getByRole('button', { name: 'Export game' }).click();
|
await page.getByRole('button', { name: 'Export game' }).click();
|
||||||
|
|
||||||
@@ -47,13 +58,9 @@ test('the export chooser downloads the GCG file on a plain browser', async ({ pa
|
|||||||
expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/);
|
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 both formats go through the native downloadFile dialog', async ({ page }) => {
|
||||||
page,
|
// A Telegram stub with downloadFile present (Bot API 8.0): the image option is offered
|
||||||
}) => {
|
// and the artifact is handed to the native download — never an in-webview anchor.
|
||||||
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).
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
Object.assign(window, {
|
Object.assign(window, {
|
||||||
Telegram: {
|
Telegram: {
|
||||||
@@ -62,9 +69,9 @@ test('inside Telegram the chooser is the app modal and withholds the image optio
|
|||||||
initDataUnsafe: {},
|
initDataUnsafe: {},
|
||||||
ready() {},
|
ready() {},
|
||||||
expand() {},
|
expand() {},
|
||||||
showPopup(_params: unknown, cb: (id: string) => void) {
|
downloadFile(params: { url: string; file_name: string }) {
|
||||||
(window as unknown as { __popupCalled?: boolean }).__popupCalled = true;
|
const w = window as unknown as { __downloads?: { url: string; file_name: string }[] };
|
||||||
setTimeout(() => cb(''), 0);
|
(w.__downloads ??= []).push(params);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -77,11 +84,49 @@ test('inside Telegram the chooser is the app modal and withholds the image optio
|
|||||||
await page.locator('.scoreboard').click();
|
await page.locator('.scoreboard').click();
|
||||||
await page.getByRole('button', { name: 'Export game' }).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…
|
await page.getByRole('button', { name: 'Image (PNG)' }).click();
|
||||||
|
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 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: 'GCG file' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0);
|
||||||
// …and the native popup was never used for the chooser.
|
await page.getByRole('button', { name: 'GCG file' }).click();
|
||||||
expect(
|
await expect(page.getByText('GCG copied to the clipboard.')).toBeVisible();
|
||||||
await page.evaluate(() => (window as unknown as { __popupCalled?: boolean }).__popupCalled),
|
|
||||||
).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|||||||
+42
-27
@@ -22,12 +22,12 @@
|
|||||||
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { hintsLeft } from '../lib/hints';
|
import { hintsLeft } from '../lib/hints';
|
||||||
import { shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share';
|
import { downloadUrl, shareOrDownloadGcg } from '../lib/share';
|
||||||
import { insideVK, vkCopyText } from '../lib/vk';
|
import { insideVK, vkCopyText, vkDownloadFile } from '../lib/vk';
|
||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
import { patchLobbyGame } from '../lib/lobbycache';
|
import { patchLobbyGame } from '../lib/lobbycache';
|
||||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||||
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
|
import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramShowConfirm } from '../lib/telegram';
|
||||||
import { haptic } from '../lib/haptics';
|
import { haptic } from '../lib/haptics';
|
||||||
import {
|
import {
|
||||||
BLANK,
|
BLANK,
|
||||||
@@ -931,26 +931,30 @@
|
|||||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
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 —
|
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
||||||
// behind one 📤 button, always through the app's own modal. Never Telegram's native popup
|
// image — behind one 📤 button, always through the app's own modal. Never Telegram's
|
||||||
// here: its callback runs with NO user activation, so navigator.share / clipboard writes
|
// native popup here: its callback runs with NO user activation, which breaks the
|
||||||
// silently fail from it (verified on-device, TG iOS + Android). A real DOM button click in
|
// gesture-gated delivery APIs (verified on-device). Both formats travel the SAME route on
|
||||||
// the modal keeps the gesture alive for the delivery APIs.
|
// every platform: mint a signed relative URL, resolve it against our origin, then hand it
|
||||||
|
// to the platform's native download call — Telegram downloadFile / VKWebAppDownloadFile —
|
||||||
|
// or a plain browser anchor download elsewhere (including the desktop VK iframe, where
|
||||||
|
// the bridge method is unavailable and vkDownloadFile falls through).
|
||||||
let exportOpen = $state(false);
|
let exportOpen = $state(false);
|
||||||
// The PNG option is withheld in an in-app WebView (Telegram / VK) for now: a binary image
|
// The only platform without the URL delivery is a legacy Telegram client predating
|
||||||
// has no working delivery there (no Web Share, a dead <a download>, text-only clipboard,
|
// Bot API 8.0 downloadFile: the GCG falls back to the old clipboard copy there, and
|
||||||
// and the long-press menu mangles data: URLs). It returns with the server-rendered
|
// the image option is not offered.
|
||||||
// signed-URL delivery (Telegram downloadFile / VKWebAppDownloadFile).
|
const exportImageAvailable = $derived(!insideTelegram() || telegramCanDownloadFile());
|
||||||
const exportImageAvailable = $derived(!(insideTelegram() || insideVK()));
|
|
||||||
|
|
||||||
function onExportClick() {
|
function onExportClick() {
|
||||||
exportOpen = true;
|
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 {
|
try {
|
||||||
const gcg = await gateway.exportGcg(id);
|
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');
|
if (outcome === 'copied') showToast(t('game.gcgCopied'), 'info');
|
||||||
else if (outcome === 'failed') showToast(t('error.generic'), 'error');
|
else if (outcome === 'failed') showToast(t('error.generic'), 'error');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -958,17 +962,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// exportImage renders the finished game as a PNG (lib/gameimage, dynamically imported so the
|
// exportArtifact delivers a finished game's artifact by the unified signed-URL route.
|
||||||
// renderer stays out of the startup bundle) and delivers it — Web Share where the platform
|
// The device date locale and the UI-localized non-play labels ride the URL so the
|
||||||
// supports it, else a download (only reachable outside the in-app WebViews, see
|
// server-rendered image matches what the player would have seen locally.
|
||||||
// exportImageAvailable).
|
const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const;
|
||||||
async function exportImage() {
|
async function exportArtifact(kind: 'png' | 'gcg') {
|
||||||
if (!view) return;
|
if (insideTelegram() && !telegramCanDownloadFile()) {
|
||||||
|
void exportGcgLegacy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { renderGameImage } = await import('../lib/gameimage');
|
const labels = EXPORT_ACTIONS.map((a) => t(`move.${a}` as MessageKey));
|
||||||
const blob = await renderGameImage(view.game, moves, { actionLabel: moveActionLabel });
|
const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : '';
|
||||||
const file = new File([blob], `game-${id.slice(0, 8)}.png`, { type: 'image/png' });
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
||||||
await shareOrDownloadImage(file);
|
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
|
||||||
|
const url = new URL(path, location.origin).href;
|
||||||
|
if (insideTelegram() && telegramDownloadFile(url, filename)) return;
|
||||||
|
if (insideVK() && (await vkDownloadFile(url, filename))) return;
|
||||||
|
downloadUrl(url, filename);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
@@ -1492,13 +1503,17 @@
|
|||||||
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
||||||
<div class="export-opts">
|
<div class="export-opts">
|
||||||
{#if exportImageAvailable}
|
{#if exportImageAvailable}
|
||||||
<button class="confirm" onclick={() => { exportOpen = false; void exportImage(); }}>
|
<button
|
||||||
|
class="confirm"
|
||||||
|
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
||||||
|
disabled={!connection.online}
|
||||||
|
>
|
||||||
{t('game.exportImageOpt')}
|
{t('game.exportImageOpt')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
||||||
onclick={() => { exportOpen = false; void exportGcg(); }}
|
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
||||||
disabled={!connection.online}
|
disabled={!connection.online}
|
||||||
>
|
>
|
||||||
{t('game.exportGcgOpt')}
|
{t('game.exportGcgOpt')}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
|
|||||||
export { EvalRequest } from './scrabblefb/eval-request.js';
|
export { EvalRequest } from './scrabblefb/eval-request.js';
|
||||||
export { EvalResult } from './scrabblefb/eval-result.js';
|
export { EvalResult } from './scrabblefb/eval-result.js';
|
||||||
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
|
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
|
||||||
|
export { ExportUrl } from './scrabblefb/export-url.js';
|
||||||
|
export { ExportUrlRequest } from './scrabblefb/export-url-request.js';
|
||||||
export { FeedbackReply } from './scrabblefb/feedback-reply.js';
|
export { FeedbackReply } from './scrabblefb/feedback-reply.js';
|
||||||
export { FeedbackState } from './scrabblefb/feedback-state.js';
|
export { FeedbackState } from './scrabblefb/feedback-state.js';
|
||||||
export { FeedbackSubmitRequest } from './scrabblefb/feedback-submit-request.js';
|
export { FeedbackSubmitRequest } from './scrabblefb/feedback-submit-request.js';
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class ExportUrlRequest {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):ExportUrlRequest {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsExportUrlRequest(bb:flatbuffers.ByteBuffer, obj?:ExportUrlRequest):ExportUrlRequest {
|
||||||
|
return (obj || new ExportUrlRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsExportUrlRequest(bb:flatbuffers.ByteBuffer, obj?:ExportUrlRequest):ExportUrlRequest {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new ExportUrlRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
gameId():string|null
|
||||||
|
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
kind():string|null
|
||||||
|
kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
kind(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dateLocale():string|null
|
||||||
|
dateLocale(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
dateLocale(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionLabels(index: number):string
|
||||||
|
actionLabels(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||||
|
actionLabels(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||||
|
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionLabelsLength():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||||
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeZone():string|null
|
||||||
|
timeZone(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
timeZone(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startExportUrlRequest(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, kindOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addDateLocale(builder:flatbuffers.Builder, dateLocaleOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, dateLocaleOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addActionLabels(builder:flatbuffers.Builder, actionLabelsOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(3, actionLabelsOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createActionLabelsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||||
|
builder.startVector(4, data.length, 4);
|
||||||
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
|
builder.addOffset(data[i]!);
|
||||||
|
}
|
||||||
|
return builder.endVector();
|
||||||
|
}
|
||||||
|
|
||||||
|
static startActionLabelsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||||
|
builder.startVector(4, numElems, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addTimeZone(builder:flatbuffers.Builder, timeZoneOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(4, timeZoneOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endExportUrlRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createExportUrlRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, kindOffset:flatbuffers.Offset, dateLocaleOffset:flatbuffers.Offset, actionLabelsOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
ExportUrlRequest.startExportUrlRequest(builder);
|
||||||
|
ExportUrlRequest.addGameId(builder, gameIdOffset);
|
||||||
|
ExportUrlRequest.addKind(builder, kindOffset);
|
||||||
|
ExportUrlRequest.addDateLocale(builder, dateLocaleOffset);
|
||||||
|
ExportUrlRequest.addActionLabels(builder, actionLabelsOffset);
|
||||||
|
ExportUrlRequest.addTimeZone(builder, timeZoneOffset);
|
||||||
|
return ExportUrlRequest.endExportUrlRequest(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class ExportUrl {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):ExportUrl {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsExportUrl(bb:flatbuffers.ByteBuffer, obj?:ExportUrl):ExportUrl {
|
||||||
|
return (obj || new ExportUrl()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsExportUrl(bb:flatbuffers.ByteBuffer, obj?:ExportUrl):ExportUrl {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new ExportUrl()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
path():string|null
|
||||||
|
path(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
path(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
filename():string|null
|
||||||
|
filename(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
filename(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startExportUrl(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addPath(builder:flatbuffers.Builder, pathOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, pathOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addFilename(builder:flatbuffers.Builder, filenameOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, filenameOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endExportUrl(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createExportUrl(builder:flatbuffers.Builder, pathOffset:flatbuffers.Offset, filenameOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
ExportUrl.startExportUrl(builder);
|
||||||
|
ExportUrl.addPath(builder, pathOffset);
|
||||||
|
ExportUrl.addFilename(builder, filenameOffset);
|
||||||
|
return ExportUrl.endExportUrl(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
FriendCode,
|
FriendCode,
|
||||||
GameList,
|
GameList,
|
||||||
GameView,
|
GameView,
|
||||||
|
ExportUrl,
|
||||||
GcgExport,
|
GcgExport,
|
||||||
History,
|
History,
|
||||||
HintResult,
|
HintResult,
|
||||||
@@ -161,6 +162,10 @@ export interface GatewayClient {
|
|||||||
profileUpdate(p: ProfileUpdate): Promise<Profile>;
|
profileUpdate(p: ProfileUpdate): Promise<Profile>;
|
||||||
statsGet(): Promise<Stats>;
|
statsGet(): Promise<Stats>;
|
||||||
exportGcg(gameId: string): Promise<GcgExport>;
|
exportGcg(gameId: string): Promise<GcgExport>;
|
||||||
|
/** Mints a signed download URL for a finished game's artifact. dateLocale and the
|
||||||
|
* localized non-play labels (pass/exchange/resign/timeout, in that order) ride the
|
||||||
|
* URL so the server-rendered image matches the player's presentation. */
|
||||||
|
exportUrl(gameId: string, kind: 'png' | 'gcg', dateLocale: string, actionLabels: string[], timeZone: string): Promise<ExportUrl>;
|
||||||
|
|
||||||
// --- account linking & merge ---
|
// --- account linking & merge ---
|
||||||
linkEmailRequest(email: string): Promise<void>;
|
linkEmailRequest(email: string): Promise<void>;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
decodeBlockStatus,
|
decodeBlockStatus,
|
||||||
decodeDraftView,
|
decodeDraftView,
|
||||||
decodeEvent,
|
decodeEvent,
|
||||||
|
decodeExportUrl,
|
||||||
decodeFriendList,
|
decodeFriendList,
|
||||||
decodeGameList,
|
decodeGameList,
|
||||||
decodeInvitation,
|
decodeInvitation,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
encodeDraftSave,
|
encodeDraftSave,
|
||||||
encodeEnqueue,
|
encodeEnqueue,
|
||||||
encodeExchange,
|
encodeExchange,
|
||||||
|
encodeExportUrlRequest,
|
||||||
encodeGuestLogin,
|
encodeGuestLogin,
|
||||||
encodeStateRequest,
|
encodeStateRequest,
|
||||||
encodeSubmitPlay,
|
encodeSubmitPlay,
|
||||||
@@ -34,6 +36,30 @@ import {
|
|||||||
} from './codec';
|
} from './codec';
|
||||||
|
|
||||||
describe('codec', () => {
|
describe('codec', () => {
|
||||||
|
it('round-trips the export-url request and response', () => {
|
||||||
|
const req = fb.ExportUrlRequest.getRootAsExportUrlRequest(
|
||||||
|
new ByteBuffer(
|
||||||
|
encodeExportUrlRequest('g-1', 'png', 'ru-RU', ['пас', 'обмен', 'сдался', 'таймаут'], 'Europe/Moscow'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(req.gameId()).toBe('g-1');
|
||||||
|
expect(req.kind()).toBe('png');
|
||||||
|
expect(req.dateLocale()).toBe('ru-RU');
|
||||||
|
expect(req.timeZone()).toBe('Europe/Moscow');
|
||||||
|
expect(req.actionLabelsLength()).toBe(4);
|
||||||
|
expect(req.actionLabels(1)).toBe('обмен');
|
||||||
|
|
||||||
|
const b = new Builder(128);
|
||||||
|
const path = b.createString('/dl/g-1/png?e=1&s=x');
|
||||||
|
const fn = b.createString('game-g-1.png');
|
||||||
|
fb.ExportUrl.startExportUrl(b);
|
||||||
|
fb.ExportUrl.addPath(b, path);
|
||||||
|
fb.ExportUrl.addFilename(b, fn);
|
||||||
|
b.finish(fb.ExportUrl.endExportUrl(b));
|
||||||
|
const out = decodeExportUrl(b.asUint8Array());
|
||||||
|
expect(out).toEqual({ path: '/dl/g-1/png?e=1&s=x', filename: 'game-g-1.png' });
|
||||||
|
});
|
||||||
|
|
||||||
it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => {
|
it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => {
|
||||||
for (const vsAi of [false, true]) {
|
for (const vsAi of [false, true]) {
|
||||||
const req = fb.EnqueueRequest.getRootAsEnqueueRequest(
|
const req = fb.EnqueueRequest.getRootAsEnqueueRequest(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
EvalResult,
|
EvalResult,
|
||||||
FeedbackState,
|
FeedbackState,
|
||||||
FriendCode,
|
FriendCode,
|
||||||
|
ExportUrl,
|
||||||
GameList,
|
GameList,
|
||||||
GameView,
|
GameView,
|
||||||
GcgExport,
|
GcgExport,
|
||||||
@@ -882,6 +883,36 @@ export function decodeGcg(buf: Uint8Array): GcgExport {
|
|||||||
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
|
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeExportUrlRequest(
|
||||||
|
gameId: string,
|
||||||
|
kind: 'png' | 'gcg',
|
||||||
|
dateLocale: string,
|
||||||
|
actionLabels: string[],
|
||||||
|
timeZone: string,
|
||||||
|
): Uint8Array {
|
||||||
|
const b = new Builder(256);
|
||||||
|
const gid = b.createString(gameId);
|
||||||
|
const k = b.createString(kind);
|
||||||
|
const dl = b.createString(dateLocale);
|
||||||
|
const tz = b.createString(timeZone);
|
||||||
|
const labels = fb.ExportUrlRequest.createActionLabelsVector(
|
||||||
|
b,
|
||||||
|
actionLabels.map((l) => b.createString(l)),
|
||||||
|
);
|
||||||
|
fb.ExportUrlRequest.startExportUrlRequest(b);
|
||||||
|
fb.ExportUrlRequest.addGameId(b, gid);
|
||||||
|
fb.ExportUrlRequest.addKind(b, k);
|
||||||
|
fb.ExportUrlRequest.addDateLocale(b, dl);
|
||||||
|
fb.ExportUrlRequest.addActionLabels(b, labels);
|
||||||
|
fb.ExportUrlRequest.addTimeZone(b, tz);
|
||||||
|
return finish(b, fb.ExportUrlRequest.endExportUrlRequest(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeExportUrl(buf: Uint8Array): ExportUrl {
|
||||||
|
const e = fb.ExportUrl.getRootAsExportUrl(new ByteBuffer(buf));
|
||||||
|
return { path: s(e.path()), filename: s(e.filename()) };
|
||||||
|
}
|
||||||
|
|
||||||
function emptyGame(): GameView {
|
function emptyGame(): GameView {
|
||||||
return {
|
return {
|
||||||
id: '',
|
id: '',
|
||||||
|
|||||||
+38
-26
@@ -1,12 +1,12 @@
|
|||||||
// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
|
// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
|
||||||
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
|
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
|
||||||
// Canvas 2D — no dependencies — and the module is reached only through a dynamic import, so
|
// Canvas 2D — no dependencies. The module is the SHARED source of truth for the export
|
||||||
// it stays out of the startup bundle. The scoresheet typography is fixed; the board instead
|
// image: the render sidecar bundles it (renderer/src/entry.ts) and rasterizes on
|
||||||
// stretches to the scoresheet's height (never below MIN_SIDE), so a long game grows the
|
// skia-canvas, while this ui project keeps the pure parts unit-tested (the browser app
|
||||||
// board rather than leaving a hole under it.
|
// itself no longer imports it — delivery is the server's signed URL).
|
||||||
//
|
//
|
||||||
// buildScoresheet and layoutImage are pure (vitest-covered); renderGameImage is the thin
|
// buildScoresheet and layoutImage are pure (vitest-covered); drawGameImage is the thin
|
||||||
// canvas pass exercised by the Playwright e2e.
|
// canvas pass exercised by the sidecar's node test.
|
||||||
|
|
||||||
import type { GameView, MoveRecord, Variant } from './model';
|
import type { GameView, MoveRecord, Variant } from './model';
|
||||||
import { replay } from './board';
|
import { replay } from './board';
|
||||||
@@ -56,16 +56,22 @@ export interface Scoresheet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
|
/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
|
||||||
* (regional date conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
|
* and time zone (regional conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
|
||||||
* dateLocale is for tests; production passes undefined = the system default. */
|
* Both ride in from the client on a server render; an invalid zone falls back to the
|
||||||
export function formatFinishedDate(unixSec: number, dateLocale?: string): string {
|
* process default rather than failing the whole image. */
|
||||||
return new Intl.DateTimeFormat(dateLocale, {
|
export function formatFinishedDate(unixSec: number, dateLocale?: string, timeZone?: string): string {
|
||||||
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
}).format(new Date(unixSec * 1000));
|
};
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone }).format(new Date(unixSec * 1000));
|
||||||
|
} catch {
|
||||||
|
return new Intl.DateTimeFormat(dateLocale, opts).format(new Date(unixSec * 1000));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,7 +84,7 @@ export function buildScoresheet(
|
|||||||
game: GameView,
|
game: GameView,
|
||||||
moves: MoveRecord[],
|
moves: MoveRecord[],
|
||||||
actionLabel: (action: string) => string,
|
actionLabel: (action: string) => string,
|
||||||
opts?: { hostname?: string; dateLocale?: string },
|
opts?: { hostname?: string; dateLocale?: string; timeZone?: string },
|
||||||
): Scoresheet {
|
): Scoresheet {
|
||||||
const seats = game.seats;
|
const seats = game.seats;
|
||||||
const grid = historyGrid(moves, seats.length, null);
|
const grid = historyGrid(moves, seats.length, null);
|
||||||
@@ -106,7 +112,7 @@ export function buildScoresheet(
|
|||||||
rows,
|
rows,
|
||||||
adjustments,
|
adjustments,
|
||||||
hasAdjustments: adjustments.some((a) => a !== 0),
|
hasAdjustments: adjustments.some((a) => a !== 0),
|
||||||
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale)}`,
|
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale, opts?.timeZone)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,27 +196,36 @@ export interface RenderOptions {
|
|||||||
hostname?: string;
|
hostname?: string;
|
||||||
/** Footer date locale override (defaults to the device locale) — for tests. */
|
/** Footer date locale override (defaults to the device locale) — for tests. */
|
||||||
dateLocale?: string;
|
dateLocale?: string;
|
||||||
|
/** Footer IANA time zone (the device zone on a server render; defaults to local). */
|
||||||
|
timeZone?: string;
|
||||||
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
|
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
|
||||||
scale?: number;
|
scale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CanvasLike is the minimal canvas surface drawGameImage needs — satisfied by both the
|
||||||
|
* browser HTMLCanvasElement and the render sidecar's skia-canvas Canvas, so the drawing
|
||||||
|
* code is shared verbatim between the two runtimes. */
|
||||||
|
export interface CanvasLike {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
getContext(contextId: '2d'): CanvasRenderingContext2D | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* renderGameImage draws the finished game as a PNG blob: final board with classic axes on
|
* drawGameImage sizes the canvas and draws the finished game onto it: final board with
|
||||||
* the left, the scoresheet on the right, the site + finish date at the bottom. Light theme
|
* classic axes on the left, the scoresheet on the right, the site + finish date at the
|
||||||
* always; no last-move highlight (an archival artifact, not a live frame).
|
* bottom. Light theme always; no last-move highlight (an archival artifact, not a live
|
||||||
|
* frame). The caller owns the canvas and its PNG encoding (toBlob in the browser,
|
||||||
|
* toBuffer in the sidecar).
|
||||||
*/
|
*/
|
||||||
export async function renderGameImage(
|
export function drawGameImage(canvas: CanvasLike, game: GameView, moves: MoveRecord[], opts: RenderOptions): void {
|
||||||
game: GameView,
|
|
||||||
moves: MoveRecord[],
|
|
||||||
opts: RenderOptions,
|
|
||||||
): Promise<Blob> {
|
|
||||||
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
|
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
|
||||||
hostname: opts.hostname,
|
hostname: opts.hostname,
|
||||||
dateLocale: opts.dateLocale,
|
dateLocale: opts.dateLocale,
|
||||||
|
timeZone: opts.timeZone,
|
||||||
});
|
});
|
||||||
const layout = layoutImage(sheet);
|
const layout = layoutImage(sheet);
|
||||||
const scale = opts.scale ?? 2;
|
const scale = opts.scale ?? 2;
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = Math.round(layout.w * scale);
|
canvas.width = Math.round(layout.w * scale);
|
||||||
canvas.height = Math.round(layout.h * scale);
|
canvas.height = Math.round(layout.h * scale);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -228,12 +243,9 @@ export async function renderGameImage(
|
|||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
|
ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
|
||||||
|
|
||||||
return new Promise<Blob>((resolve, reject) => {
|
|
||||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('gameimage: toBlob failed'))), 'image/png');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
|
function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
|
||||||
const { x, y, cell } = l.board;
|
const { x, y, cell } = l.board;
|
||||||
ctx.fillStyle = C.muted;
|
ctx.fillStyle = C.muted;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
FeedbackState,
|
FeedbackState,
|
||||||
FriendCode,
|
FriendCode,
|
||||||
GameList,
|
GameList,
|
||||||
|
ExportUrl,
|
||||||
GcgExport,
|
GcgExport,
|
||||||
History,
|
History,
|
||||||
HintResult,
|
HintResult,
|
||||||
@@ -677,6 +678,12 @@ export class MockGateway implements GatewayClient {
|
|||||||
content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
|
content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async exportUrl(gameId: string, kind: 'png' | 'gcg'): Promise<ExportUrl> {
|
||||||
|
const g = this.game(gameId);
|
||||||
|
if (g.view.status !== 'finished') throw new GatewayError('game_active');
|
||||||
|
// A shape-faithful signed path; the e2e intercepts the route and serves bytes.
|
||||||
|
return { path: `/dl/${gameId}/${kind}?e=9999999999&s=mock`, filename: `game-${gameId}.${kind}` };
|
||||||
|
}
|
||||||
|
|
||||||
// --- live stream ---
|
// --- live stream ---
|
||||||
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
|
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
|
||||||
|
|||||||
@@ -326,6 +326,14 @@ export interface GcgExport {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A minted signed download link for a finished game's export artifact. path is
|
||||||
|
* relative — the client resolves it against its own origin (the SPA and the
|
||||||
|
* gateway edge share it), so the server never needs to know the public host. */
|
||||||
|
export interface ExportUrl {
|
||||||
|
path: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
token: string;
|
token: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const READ_OPS: ReadonlySet<string> = new Set([
|
|||||||
'game.state',
|
'game.state',
|
||||||
'game.history',
|
'game.history',
|
||||||
'game.gcg',
|
'game.gcg',
|
||||||
|
'game.export_url',
|
||||||
'game.evaluate',
|
'game.evaluate',
|
||||||
'game.check_word',
|
'game.check_word',
|
||||||
'stats.get',
|
'stats.get',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { pickGcgDelivery, pickImageDelivery, pickTextShare, shareOrDownloadGcg, shareOrDownloadImage, shareText } from './share';
|
import { downloadUrl, pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share';
|
||||||
import type { GcgExport } from './model';
|
import type { GcgExport } from './model';
|
||||||
|
|
||||||
const file = {} as File;
|
const file = {} as File;
|
||||||
@@ -83,52 +83,21 @@ describe('shareOrDownloadGcg', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pickImageDelivery', () => {
|
describe('downloadUrl', () => {
|
||||||
const canShareNav = { canShare: () => true, share: async () => {} };
|
|
||||||
const noShareNav = { canShare: () => false, share: async () => {} };
|
|
||||||
|
|
||||||
it('shares when the platform can share files', () => {
|
|
||||||
expect(pickImageDelivery(canShareNav, file)).toBe('share');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('downloads on a plain browser without Web Share (desktop)', () => {
|
|
||||||
expect(pickImageDelivery(noShareNav, file)).toBe('download');
|
|
||||||
expect(pickImageDelivery(undefined, file)).toBe('download');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shareOrDownloadImage', () => {
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
function stubEnv(canShare: boolean, share: () => Promise<void>) {
|
it('clicks a temporary anchor carrying the URL and the filename', () => {
|
||||||
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
||||||
const createElement = vi.fn(() => anchor);
|
const createElement = vi.fn(() => anchor);
|
||||||
vi.stubGlobal('navigator', { canShare: () => canShare, share });
|
|
||||||
vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } });
|
vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } });
|
||||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:x'), revokeObjectURL: vi.fn() });
|
|
||||||
return { anchor, createElement };
|
|
||||||
}
|
|
||||||
|
|
||||||
const png = () => ({ name: 'game-1.png', type: 'image/png' }) as File;
|
downloadUrl('https://example.test/dl/g-1/png?e=1&s=x', 'game-1.png');
|
||||||
|
|
||||||
it('never falls back to the navigating Blob download when a share is cancelled', async () => {
|
|
||||||
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
|
|
||||||
const { createElement } = stubEnv(true, share);
|
|
||||||
|
|
||||||
expect(await shareOrDownloadImage(png())).toBe('shared');
|
|
||||||
|
|
||||||
expect(share).toHaveBeenCalledOnce();
|
|
||||||
expect(createElement).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('downloads via an anchor on a desktop browser that cannot share files', async () => {
|
|
||||||
const { anchor, createElement } = stubEnv(false, vi.fn());
|
|
||||||
|
|
||||||
expect(await shareOrDownloadImage(png())).toBe('downloaded');
|
|
||||||
|
|
||||||
expect(createElement).toHaveBeenCalledWith('a');
|
expect(createElement).toHaveBeenCalledWith('a');
|
||||||
expect(anchor.click).toHaveBeenCalledOnce();
|
expect(anchor.href).toBe('https://example.test/dl/g-1/png?e=1&s=x');
|
||||||
expect(anchor.download).toBe('game-1.png');
|
expect(anchor.download).toBe('game-1.png');
|
||||||
|
expect(anchor.click).toHaveBeenCalledOnce();
|
||||||
|
expect(anchor.remove).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+13
-36
@@ -81,43 +81,20 @@ function downloadFile(content: Blob | string, filename: string, type = 'applicat
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* pickImageDelivery decides how to deliver the PNG game image: Web Share (with the file)
|
* downloadUrl saves a same-origin signed export URL as a file through a temporary
|
||||||
* wherever it exists — mobile browsers and the iOS Telegram Mini App — else a Blob download.
|
* anchor: the server names it via Content-Disposition, the download attribute is the
|
||||||
* The image option is not offered inside the in-app WebViews at all (Android Telegram/VK, the
|
* same-origin hint. This is the browser leg of the unified export delivery; the in-app
|
||||||
* desktop VK iframe): a binary PNG has no working route there until the server-rendered
|
* WebViews never reach it (they use the platform's native download call instead), so
|
||||||
* signed-URL delivery lands, so this decision never sees them. Pure, unit-tested with a mock
|
* the iOS blob-URL navigation trap does not apply — this is a real https URL.
|
||||||
* navigator.
|
|
||||||
*/
|
*/
|
||||||
export function pickImageDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' {
|
export function downloadUrl(url: string, filename: string): void {
|
||||||
if (
|
if (typeof document === 'undefined') return;
|
||||||
nav &&
|
const a = document.createElement('a');
|
||||||
typeof nav.canShare === 'function' &&
|
a.href = url;
|
||||||
typeof nav.share === 'function' &&
|
a.download = filename;
|
||||||
nav.canShare({ files: [file] })
|
document.body.appendChild(a);
|
||||||
) {
|
a.click();
|
||||||
return 'share';
|
a.remove();
|
||||||
}
|
|
||||||
return 'download';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* shareOrDownloadImage delivers the rendered game image and reports the route taken. The Web
|
|
||||||
* Share invariant matches shareOrDownloadGcg: a cancelled or failed share is a deliberate
|
|
||||||
* no-op, never a Blob-download fallback (an <a download> strands the iOS WKWebView on the
|
|
||||||
* blob: URL).
|
|
||||||
*/
|
|
||||||
export async function shareOrDownloadImage(file: File): Promise<'shared' | 'downloaded'> {
|
|
||||||
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
|
||||||
if (pickImageDelivery(nav, file) === 'share' && nav) {
|
|
||||||
try {
|
|
||||||
await nav.share({ files: [file], title: file.name });
|
|
||||||
} catch {
|
|
||||||
/* cancelled or failed — intentionally a no-op (see shareOrDownloadGcg) */
|
|
||||||
}
|
|
||||||
return 'shared';
|
|
||||||
}
|
|
||||||
downloadFile(file, file.name, file.type);
|
|
||||||
return 'downloaded';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
|
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
|
||||||
|
|||||||
+20
-1
@@ -67,6 +67,7 @@ interface TelegramWebApp {
|
|||||||
};
|
};
|
||||||
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
|
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
|
||||||
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
|
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
|
||||||
|
downloadFile?: (params: { url: string; file_name: string }, cb?: (accepted: boolean) => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webApp(): TelegramWebApp | undefined {
|
function webApp(): TelegramWebApp | undefined {
|
||||||
@@ -372,7 +373,8 @@ export function telegramShowConfirm(message: string): Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
|
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
|
||||||
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
|
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
|
||||||
* predating the popup, so callers can fall back to their own modal.
|
* predating the popup, so callers can fall back to their own modal. NOTE: the callback runs with
|
||||||
|
* no user activation — never lead from it into navigator.share or a clipboard write.
|
||||||
*/
|
*/
|
||||||
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
|
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
|
||||||
const w = webApp();
|
const w = webApp();
|
||||||
@@ -380,6 +382,23 @@ export function telegramShowPopup(params: TelegramPopupParams): Promise<string |
|
|||||||
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
|
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** telegramCanDownloadFile reports whether the native download dialog exists (Bot API 8.0). */
|
||||||
|
export function telegramCanDownloadFile(): boolean {
|
||||||
|
return !!webApp()?.downloadFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramDownloadFile asks Telegram to download url as fileName through its native
|
||||||
|
* dialog — the Mini App file delivery that works on every Telegram platform (a webview
|
||||||
|
* <a download> does not). Returns false outside Telegram or on a client predating it.
|
||||||
|
*/
|
||||||
|
export function telegramDownloadFile(url: string, fileName: string): boolean {
|
||||||
|
const w = webApp();
|
||||||
|
if (!w?.downloadFile) return false;
|
||||||
|
w.downloadFile({ url, file_name: fileName });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Haptic is the set of feedbacks the app triggers. */
|
/** Haptic is the set of feedbacks the app triggers. */
|
||||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async exportGcg(gameId) {
|
async exportGcg(gameId) {
|
||||||
return codec.decodeGcg(await exec('game.gcg', codec.encodeGameAction(gameId)));
|
return codec.decodeGcg(await exec('game.gcg', codec.encodeGameAction(gameId)));
|
||||||
},
|
},
|
||||||
|
async exportUrl(gameId, kind, dateLocale, actionLabels, timeZone) {
|
||||||
|
return codec.decodeExportUrl(
|
||||||
|
await exec('game.export_url', codec.encodeExportUrlRequest(gameId, kind, dateLocale, actionLabels, timeZone)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
subscribe(onEvent, onError) {
|
subscribe(onEvent, onError) {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ export async function vkShare(link: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
|
||||||
|
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
||||||
|
* on any failure or where the method is unavailable (notably the desktop iframe), so the
|
||||||
|
* caller falls back to a plain browser download of the same URL.
|
||||||
|
*/
|
||||||
|
export async function vkDownloadFile(url: string, filename: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await (await bridge()).send('VKWebAppDownloadFile', { url, filename });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
|
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
|
||||||
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
|
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
|
||||||
|
|||||||
Reference in New Issue
Block a user