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,310 @@
|
||||
// Finished-game export downloads: a signed, short-lived, relative URL minted for a
|
||||
// participant and later fetched with no credentials at all — the platforms' native
|
||||
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain browser
|
||||
// anchor) strip cookies and headers, so the HMAC in the URL is the whole grant.
|
||||
// The client resolves the relative path against its own origin; the edge routes
|
||||
// /dl/* to the gateway, which forwards it here (/api/v1/public/dl/...).
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// exportURLTTL is how long a minted download URL stays valid. Long enough for the
|
||||
// platform's download dialog and a retry, short enough that a leaked link dies fast.
|
||||
const exportURLTTL = 10 * time.Minute
|
||||
|
||||
// Query/label size caps: the presentation params ride the signed URL, so they are
|
||||
// bounded defensively (they only ever hold a BCP-47 tag and four short UI words).
|
||||
const (
|
||||
maxDateLocaleLen = 35
|
||||
maxLabelsLen = 120
|
||||
maxTimeZoneLen = 50
|
||||
)
|
||||
|
||||
// exportActionOrder fixes the position of each non-play move label in the `t` CSV.
|
||||
var exportActionOrder = [4]string{"pass", "exchange", "resign", "timeout"}
|
||||
|
||||
// signExport computes the URL signature over the canonical parameter string. The
|
||||
// canonical form is independent of the public path prefix, so the gateway may mount
|
||||
// the route anywhere.
|
||||
func (s *Server) signExport(gameID, kind, exp, dateLocale, timeZone, labels string) string {
|
||||
mac := hmac.New(sha256.New, s.exportKey)
|
||||
fmt.Fprintf(mac, "%s|%s|%s|%s|%s|%s", gameID, kind, exp, dateLocale, timeZone, labels)
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// exportURLDTO is the minted link: a relative signed path plus the download filename.
|
||||
type exportURLDTO struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// handleExportURL mints the signed download path for a finished game's artifact.
|
||||
// GET /api/v1/user/games/:id/export-url?kind=png|gcg&dl=<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
|
||||
}
|
||||
Reference in New Issue
Block a user