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

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:
2026-07-02 21:58:07 +00:00
parent 16a4431158
commit d5fbaa3034
62 changed files with 2449 additions and 234 deletions
+34 -5
View File
@@ -785,11 +785,40 @@ the same rows and is likewise self-contained — we ship our own writer (the sol
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
otherwise), so an in-progress journal is never leaked mid-play; the client
shares the `.gcg` file via the Web Share API where available; an Android in-app WebView
(Telegram / VK) has no Web Share and silently ignores an `<a download>`, so there it copies the GCG
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
otherwise), so an in-progress journal is never leaked mid-play.
**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text
and the rendered **PNG of the final position** — travel one uniform route on every
platform: the client calls the authenticated `game.export_url` op, the backend mints a
**relative, HMAC-signed, short-lived path**
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL,
`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no
service ever needs to know the public host) and hands it to the best affordance
each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the
chooser there is the native showPopup — activation-free bridge calls end to end),
Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both
formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a
mobile browser, or a plain anchor download (desktop browsers and the VK desktop
iframe). The gateway serves the bytes via
`http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager
hangs on chunked bodies of unknown length). The GET is the gateway's
**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the
per-IP public rate limiter): the native download calls carry no cookies or headers, so
the URL's signature — verified by the backend on its `/api/v1/public` group in constant
time, every failure a uniform 404 — is the whole grant, and minting requires an
authenticated caller on a finished game. The PNG is rasterized on demand by the
**`renderer` sidecar** (internal-only Node + skia-canvas running the same
`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift;
`renderer/README.md`); the backend rebuilds the render payload from the journal +
`engine.AlphabetTable`, and the client's date locale, IANA time zone and UI-localized
non-play labels ride the signed URL so the server render matches the player's
presentation. Nothing is stored: the artifact is re-derived from the immutable
finished journal on each GET. The only degraded platform is a legacy Telegram client
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
image option is not offered.
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
+32 -25
View File
@@ -322,33 +322,40 @@ Finished games are archived in a dictionary-independent form and exportable in
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
the final position; the export is offered **only once a game is finished**, and
never for an honest-AI practice game (a live game's export would leak the move
journal; an AI game is throwaway). The format chooser is always the app's **own
modal** — deliberately not Telegram's native popup, whose callback runs without
user activation and silently breaks the share/clipboard delivery it leads to.
journal; an AI game is throwaway). The format chooser is Telegram's **native popup**
on Telegram Android/desktop (safe there: that chain is all bridge calls, which need
no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery
is the OS share sheet, which a native-popup callback cannot open, and VK has no
native chooser.
The **image** is rendered on the client (Canvas 2D, always the light theme): the
final board with classic coordinate axes A..O / 1..15 on the left — premium squares
as plain colour fills, no text labels — and a compact per-seat scoresheet on the
right: each seat's name and final score in the header (🏆 by the winner), then one
row per move carrying the main word's classic coordinate (an across play is
row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and
its points; extra words of a multi-word play ride a smaller second line, non-play
moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row
shows the endgame rack settlement when there was one (the final scores are
authoritative — running totals alone do not include it). The footer stamps the site
host and the finish date in the device locale. The scoresheet typography is fixed;
a long game stretches the board (never below its minimum) so the image carries no
dead space.
The **image** is rendered on the server (the internal render sidecar runs the same
drawing module the web client tests; always the light theme): the final board with
classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour
fills, no text labels — and a compact per-seat scoresheet on the right: each seat's
name and final score in the header (🏆 by the winner), then one row per move
carrying the main word's classic coordinate (an across play is row-first, `8G`; a
down play column-first, `H8` — the GCG convention), the word and its points; extra
words of a multi-word play ride a smaller second line, non-play moves show as
localized notes (pass/exchange/resign/timeout), and a closing ± row shows the
endgame rack settlement when there was one (the final scores are authoritative —
running totals alone do not include it). The footer stamps the site host and the
finish date in the device locale. The scoresheet typography is fixed; a long game
stretches the board (never below its minimum) so the image carries no dead space.
Delivery per format: the **GCG** file is Web-Shared where the platform supports it;
on an Android in-app client (Telegram / VK), which has neither Web Share nor a
working file download, it copies the GCG text to the clipboard (with a confirming
toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the
same way, but a binary image has no clipboard-text fallback at all — so the image
option is currently **withheld inside the in-app webviews** (Telegram / VK) and
offered on the plain web and mobile browsers only; it returns there with the
server-rendered signed-URL delivery (Telegram `downloadFile` /
`VKWebAppDownloadFile`). Statistics (durable accounts only):
Delivery is **one signed, short-lived link for both formats on every platform**,
handed to the best affordance each platform has (each branch verified on-device):
Telegram Android/desktop use Telegram's download dialog (whose own preview shares
onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS
takes both formats through VK's download into its native share flow, while on the
**VK Android client** — whose downloader hangs — the image opens in VK's native
photo viewer (saving from its controls) and the GCG copies to the clipboard (the
desktop VK iframe, an ordinary browser, downloads both); a **mobile browser gets
the OS share sheet** (nothing lands in Downloads first); a desktop browser
downloads the file. The link needs no login to fetch (the platforms' downloaders
carry none) and is valid for minutes. The single exception is a legacy Telegram
client without the download dialog (pre-Bot API 8.0): there the GCG falls back to
the old clipboard copy (with the confirming toast) and the image option is not
offered. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). It
also shows the player's **move count** (their plays — passes and exchanges do not
+21 -13
View File
@@ -331,11 +331,14 @@ Telegram.
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения,
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
молча ломает доставку через share/буфер, к которой ведёт выбор.
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на
Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов,
которым user activation не нужна) и собственный модал приложения в остальных
случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из
коллбека нативного попапа не открыть, а у VK нативного чузера нет.
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
@@ -348,15 +351,20 @@ Telegram.
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же,
но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт
«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается
только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером
и доставкой по подписанному URL (Telegram `downloadFile` /
`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов):
Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный
share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в
нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер
(десктопный iframe VK — обычный браузер — скачивает оба); **мобильный браузер
получает системную share-шторку** (ничего не оседает в Загрузках); десктопный
браузер скачивает файл. Ссылка не требует логина при
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
+9 -2
View File
@@ -19,8 +19,15 @@ tests or touching CI.
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/copy/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the GCG export's
finished-only visibility.
invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted).
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG
(`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout,
notation) stay unit-tested in `ui/` — the sidecar test guards only the
skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across
hosts).
- **Local-eval conformance** — the client's on-device move preview (the ported
dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the
authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit
+30 -23
View File
@@ -406,29 +406,36 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); the 📤 in the history header appears
only once the game is finished — never in an honest-AI game (throwaway practice) — and
opens the **format chooser**: always the app's own modal, never Telegram's native popup
(a `showPopup` callback carries no user activation, which silently breaks the
`navigator.share` / clipboard delivery it leads to — on-device finding). The accent button
leads with the image where offered, the GCG file is the quiet alternative (GCG needs the
connection, the image renders locally). The GCG file goes by Web Share where available, a
clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor
a working download), else a Blob download. The image option shows **only outside the in-app
WebViews** for now (a binary PNG has no working route there — no Web Share, a dead
`<a download>`, a text-only clipboard, and the long-press menu mangles data: URLs); it
returns there with the server-rendered signed-URL delivery. Confirming a resign reveals
the full board: it closes the history drawer (portrait) and zooms the board out.
- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light
palette (pinned constants mirroring `app.css`), the final board with classic axes A..O /
1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style
(bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move
highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by
the winner), one fixed-typography row per move — muted classic coordinate (across =
row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra
words of a multi-word play on a smaller second line; italic localized notes for
pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when
present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's
height drives the board side (never below its minimum): a long game stretches the board,
never the typography, so the image has no dead space.
opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
there, since that chain is all bridge calls needing no user activation (a popup callback
must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's
own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal
click's activation; VK has no native chooser; the accent button leads with the image,
both options need the connection). Both formats mint the same signed relative link
(`game.export_url`), resolved against the app's own origin, then delivered per platform
(each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview
shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS
`VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose
DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save
works) and the GCG to the clipboard; the VK desktop iframe plain anchor downloads; a
mobile browser the OS share sheet (the proven fetch-then-share pattern); a desktop
browser an anchor download. A legacy Telegram
client without `downloadFile` keeps the old GCG clipboard copy, hides the image option
and stays on the app modal. Confirming a resign reveals the full board: it closes the
history drawer (portrait) and zooms the board out.
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
executes; the browser app no longer draws it): always the light palette (pinned constants
mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left,
value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is
the scoresheet: per-seat name over the final score (🏆 by the winner), one
fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down =
column-first `H8`), the main word, points right-aligned; extra words of a multi-word play
on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a
closing muted ± row for the endgame rack settlement when present. Footer:
`hostname · finish date` in the **device** locale. The scoresheet's height drives the
board side (never below its minimum): a long game stretches the board, never the
typography, so the image has no dead space.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than