feat(export): server-rendered artifacts behind one signed download URL (#160)
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin and delivered by the best affordance each platform has (five on-device review rounds): - TG Android/desktop: native showPopup chooser -> native downloadFile dialog (bridge-only chain, activation-safe). - TG iOS: app-modal chooser -> OS share sheet with the fetched file (a popup callback cannot supply the activation the sheet needs). - VK iOS: VKWebAppDownloadFile for both formats. - VK Android: the PNG opens in VK's native image viewer, the GCG copies to the clipboard (the VK Android downloader hangs on any download, Content-Length/Range notwithstanding). - VK desktop iframe / desktop browsers: plain anchor downloads. - Mobile browsers: the OS share sheet (fetch-then-share). - Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option. The PNG is rasterized on demand by the new internal `renderer` sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests; the backend rebuilds the render payload from the journal + engine.AlphabetTable, and the device date locale, IANA time zone and localized non-play labels ride the signed URL. Nothing is stored — the artifact re-derives from the immutable journal on each GET. The gateway forwards /dl/* (caddy @gateway matcher extended) behind the per-IP public rate limiter and serves bytes via http.ServeContent. Deploy: renderer service in compose + prod overlay + rolling order + prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README, renderer/README.
This commit was merged in pull request #160.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user