c3c27bba5e
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m2s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Both hops (backend attachment + gateway /dl forward) wrote the body through net/http's buffered writer, so anything over the write buffer went out Transfer-Encoding: chunked. Android's system DownloadManager — the executor behind VKWebAppDownloadFile — hangs indefinitely on a download of unknown length (observed on-device: VK Android stuck in 'downloading'); Telegram's downloader tolerates chunked, which is why only VK broke. The artifact is fully buffered anyway, so the length is known — set it on both responses.
311 lines
10 KiB
Go
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
|
|
}
|