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:
+34
-5
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user