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

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:
Ilia Denisov
2026-07-02 18:44:07 +02:00
parent 16a4431158
commit 3471d40576
60 changed files with 2216 additions and 236 deletions
+148
View File
@@ -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)
}
}
}