Files
scrabble-game/backend/internal/server/export.go
T
developer d5fbaa3034
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
feat(export): server-rendered artifacts behind one signed download URL (#160)
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.
2026-07-02 21:58:07 +00:00

311 lines
10 KiB
Go

// 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. Content-Length is set
// explicitly: a body over net/http's write buffer otherwise goes out chunked, and
// Android's system DownloadManager (which VKWebAppDownloadFile delegates to) hangs
// forever on a download of unknown length.
func serveAttachment(c *gin.Context, filename, contentType string, data []byte) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(filename)+`"`)
c.Header("Cache-Control", "private, max-age=0")
c.Header("Content-Length", strconv.Itoa(len(data)))
c.Data(http.StatusOK, contentType, data)
}
// --- the render-sidecar request payload (the ui-model shapes drawGameImage consumes) ---
type renderSeatJSON struct {
Seat int `json:"seat"`
AccountID string `json:"accountId"`
DisplayName string `json:"displayName"`
Score int `json:"score"`
HintsUsed int `json:"hintsUsed"`
IsWinner bool `json:"isWinner"`
}
type renderGameJSON struct {
ID string `json:"id"`
Variant string `json:"variant"`
Status string `json:"status"`
Players int `json:"players"`
LastActivityUnix int64 `json:"lastActivityUnix"`
Seats []renderSeatJSON `json:"seats"`
}
type renderTileJSON struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank"`
}
type renderMoveJSON struct {
Player int `json:"player"`
Action string `json:"action"`
Dir string `json:"dir"`
MainRow int `json:"mainRow"`
MainCol int `json:"mainCol"`
Tiles []renderTileJSON `json:"tiles"`
Words []string `json:"words"`
Count int `json:"count"`
Score int `json:"score"`
Total int `json:"total"`
}
type renderAlphaJSON struct {
Index int `json:"index"`
Letter string `json:"letter"`
Value int `json:"value"`
}
type renderRequestJSON struct {
Game renderGameJSON `json:"game"`
Moves []renderMoveJSON `json:"moves"`
Alphabet []renderAlphaJSON `json:"alphabet"`
Labels map[string]string `json:"labels"`
DateLocale string `json:"dateLocale"`
TimeZone string `json:"timeZone"`
Hostname string `json:"hostname"`
}
// renderPayload assembles the sidecar request from the export view. Letters are
// upper-cased for display exactly as the client codec does; the localized non-play
// labels arrive as a positional CSV (see exportActionOrder) minted into the URL.
func renderPayload(g game.Game, moves []game.HistoryMove, names []string, dateLocale, timeZone, labelsCSV, host string) (renderRequestJSON, error) {
alpha, err := engine.AlphabetTable(g.Variant)
if err != nil {
return renderRequestJSON{}, fmt.Errorf("alphabet for %s: %w", g.Variant, err)
}
req := renderRequestJSON{
Game: renderGameJSON{
ID: g.ID.String(),
Variant: g.Variant.String(),
Status: g.Status,
Players: g.Players,
},
Labels: map[string]string{},
DateLocale: dateLocale,
TimeZone: timeZone,
Hostname: host,
}
finished := g.UpdatedAt
if g.FinishedAt != nil {
finished = *g.FinishedAt
}
req.Game.LastActivityUnix = finished.Unix()
for _, st := range g.Seats {
name := st.DisplayName
if st.Seat < len(names) {
name = names[st.Seat]
}
req.Game.Seats = append(req.Game.Seats, renderSeatJSON{
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: name,
Score: st.Score, HintsUsed: st.HintsUsed, IsWinner: st.IsWinner,
})
}
for _, m := range moves {
mv := renderMoveJSON{
Player: m.Seat, Action: m.Action, Dir: m.Dir,
MainRow: m.MainRow, MainCol: m.MainCol,
Words: make([]string, 0, len(m.Words)),
Count: len(m.Exchanged), Score: m.Score, Total: m.RunningTotal,
Tiles: make([]renderTileJSON, 0, len(m.Tiles)),
}
for _, w := range m.Words {
mv.Words = append(mv.Words, strings.ToUpper(w))
}
for _, t := range m.Tiles {
mv.Tiles = append(mv.Tiles, renderTileJSON{Row: t.Row, Col: t.Col, Letter: strings.ToUpper(t.Letter), Blank: t.Blank})
}
req.Moves = append(req.Moves, mv)
}
for _, a := range alpha {
req.Alphabet = append(req.Alphabet, renderAlphaJSON{Index: int(a.Index), Letter: strings.ToUpper(a.Letter), Value: a.Value})
}
if labelsCSV != "" {
parts := strings.Split(labelsCSV, ",")
for i, action := range exportActionOrder {
if i < len(parts) && parts[i] != "" {
req.Labels[action] = parts[i]
}
}
}
return req, nil
}