// 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=&tz=&t= 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 }