Compare commits
70 Commits
v1.6.0
...
638c147cc0
| Author | SHA1 | Date | |
|---|---|---|---|
| 638c147cc0 | |||
| c702f1bdac | |||
| 01d02fcef6 | |||
| 9e0f929e40 | |||
| a29e00ee13 | |||
| 2207ac6132 | |||
| 3877b23894 | |||
| d5fbaa3034 | |||
| 16a4431158 | |||
| 422e3ccc2a | |||
| 946420db93 | |||
| a0eacf3011 | |||
| 622d3965a7 | |||
| 2ab01ed8f7 | |||
| 1ed624eaf1 | |||
| f3e2a6822b | |||
| 65063621a9 | |||
| 4e169c368d | |||
| aec915d5c1 | |||
| 5f574a765d | |||
| db17287113 | |||
| c864147982 | |||
| 2e8fa83814 | |||
| 5f87b3fa43 | |||
| 5689f7f6a3 | |||
| f0399e1bbc | |||
| e6c5198caa | |||
| 84cc56198e | |||
| 5f9b4a7a38 | |||
| 4458f0e545 | |||
| 41f26da9a7 | |||
| cf1d773c18 | |||
| 88b6761e28 | |||
| f4dbb545e0 | |||
| 71c3411276 | |||
| f9acea1d9a | |||
| adf7c55695 | |||
| 6636d7c309 | |||
| 90f2427fa7 | |||
| d0f860a33e | |||
| d1ceccf033 | |||
| 373aa2aa6d | |||
| 4a6fe318be | |||
| 00441e3657 | |||
| 3b7c7c077e | |||
| e900d592f8 | |||
| d76f1f4026 | |||
| 0ea9764a0d | |||
| 6a5ce12fab | |||
| da17c18895 | |||
| 303348ed39 | |||
| a06dfe00fc | |||
| a88678c5c6 | |||
| 500a01cf97 | |||
| d9ede77e2f | |||
| 65c194264c | |||
| 13c22734ee | |||
| 7dabcd1317 | |||
| b03e012011 | |||
| 2495446a47 | |||
| 35705f7d1e | |||
| 4f0cc81dfb | |||
| 2ba7cc3086 | |||
| 29b6c7e4d8 | |||
| 8de9fb1ecd | |||
| 8a5a5d6c4d | |||
| ea931c6680 | |||
| d0f60ee41d | |||
| b84bd1297e | |||
| 0fb6004a8b |
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: deploy-check
|
||||||
|
description: "Use before any deploy-touching change to this repo — phrases like '/deploy-check', 'is this prod-safe', 'before we deploy', 'deploy safety review', 'проверь перед деплоем', 'это безопасно для прода'. Runs a pre-deploy checklist of this project's hard-won runtime constraints against the current diff, so the crash classes that have bitten live environments get caught before shipping instead of after."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pre-deploy runtime-constraint check
|
||||||
|
|
||||||
|
Triggered before shipping anything that touches the deploy contour (Dockerfiles,
|
||||||
|
`deploy/`, Caddyfile, compose, migrations, boot guards, the Telegram side-service,
|
||||||
|
edge config). The worst frictions in this repo were never logic bugs — they were
|
||||||
|
**environment mismatches that crashed a live env and forced a redesign**. Run this
|
||||||
|
list against the diff first; turn crash-and-redesign into a single pass.
|
||||||
|
|
||||||
|
This checklist is a prompt, **not** the source of truth. The canonical detail
|
||||||
|
lives in `deploy/README.md`, `docs/ARCHITECTURE.md`, `docs/EDGE_HTTP3.md`, and the
|
||||||
|
agent memory files referenced below — read them when an item is in play, and add a
|
||||||
|
new class here when a new incident teaches one.
|
||||||
|
|
||||||
|
## How to run it
|
||||||
|
|
||||||
|
1. `git diff <base>...HEAD --stat` to see what the change actually touches.
|
||||||
|
2. For every risk class below that the diff touches, perform the **Check** and
|
||||||
|
report PASS / FAIL with the exact file:line to fix. Skip classes the diff does
|
||||||
|
not touch — say which you skipped and why.
|
||||||
|
3. Remember: **deploy-job green ≠ healthy**. CI's deploy probe has historically
|
||||||
|
passed with a dead backend (it only checked static landing+gateway). Verify the
|
||||||
|
real feature live (`/readyz`, the actual flow) after deploy, not just the green.
|
||||||
|
|
||||||
|
## Risk classes
|
||||||
|
|
||||||
|
### 1. Container user — distroless nonroot UID 65532
|
||||||
|
- **Bit us:** TLS keys `chmod 600` for the host owner crash-looped gateway + bot at
|
||||||
|
boot with "permission denied" — service images run UID 65532.
|
||||||
|
- **Check:** any new/changed mounted secret, key, or config file must be readable by
|
||||||
|
UID 65532 (`0644`, not `0600`). Scan the diff for file modes, `chmod`, and new
|
||||||
|
volume mounts. (memory: `distroless-nonroot-mounted-secrets`)
|
||||||
|
|
||||||
|
### 2. Caddy header pipeline ordering
|
||||||
|
- **Bit us:** `header_up delete` after `set` nulled the value (the honeypot tag went
|
||||||
|
empty); it passed CI and only showed up live.
|
||||||
|
- **Check:** in any Caddyfile change, verify the `set` / `delete` / `header_up`
|
||||||
|
ordering for every affected route, and test the tripwire/route on the live
|
||||||
|
contour, not just CI.
|
||||||
|
|
||||||
|
### 3. Edge Alt-Svc / HTTP3
|
||||||
|
- **Bit us:** edge advertised `Alt-Svc: h3` while UDP/443 was never exposed
|
||||||
|
(docker tcp-only + ufw tcp-only); clients cached it 30 days and stalled on dead
|
||||||
|
QUIC before falling back to h2 — Mini App "hangs on load".
|
||||||
|
- **Check:** any edge/caddy change keeps `Alt-Svc: clear` (or only advertises h3 if
|
||||||
|
UDP/443 is genuinely exposed). (memory: `tg-app-load-stall-dead-http3-altsvc`,
|
||||||
|
`docs/EDGE_HTTP3.md`)
|
||||||
|
|
||||||
|
### 4. Prod caddy config recreate
|
||||||
|
- **Bit us:** prod rolling deploy did **not** recreate caddy on a config-only change
|
||||||
|
(pinned `caddy:2-alpine` + `admin off`), so a new Caddyfile deployed GREEN but
|
||||||
|
stayed inert until a manual `docker restart`.
|
||||||
|
- **Check:** a config-only edge change must `--force-recreate` caddy in
|
||||||
|
`prod-deploy.sh` `roll()`; never trust deploy-green for edge config.
|
||||||
|
(memory: `prod-deploy-caddy-config-recreate`)
|
||||||
|
|
||||||
|
### 5. DICT_VERSION / dictionary boot
|
||||||
|
- **Bit us:** an early `DICT_VERSION` refuse-boot guard was wrong and crashed the
|
||||||
|
live env when bumped on a seeded volume; it had to be redesigned to "marker-wins".
|
||||||
|
- **Check:** any change touching `DICT_VERSION`, dict load, or the boot guard must
|
||||||
|
keep marker-wins semantics and survive a seeded volume **and** an image rollback.
|
||||||
|
`DICT_VERSION` is a required build-arg (no default), single-sourced. A new dict
|
||||||
|
goes live via the admin console upload, not a redeploy. (memory:
|
||||||
|
`dict-version-deploy-verify`, `contour-schema-change-wipe`)
|
||||||
|
|
||||||
|
### 6. Migrations — expand-contract + rollback safety
|
||||||
|
- **Bit us / risk:** a non-backward-compatible migration breaks image rollback (DB
|
||||||
|
ahead of rolled-back code).
|
||||||
|
- **Check:** migrations must be **expand-contract** (backward-compatible). A schema
|
||||||
|
change adds the maintenance window + a consistent `pg_dump` in prod-deploy. On the
|
||||||
|
**test contour**, a schema/wire-label change needs `DROP SCHEMA backend CASCADE` +
|
||||||
|
backend restart (new code vs old persisted DB), else the contour breaks. (memory:
|
||||||
|
`contour-schema-change-wipe`)
|
||||||
|
|
||||||
|
### 7. Telegram permission model
|
||||||
|
- **Bit us:** permissions are an **AND-intersection** — default-allow with explicit
|
||||||
|
denies, not default-deny; inverting it broke access.
|
||||||
|
- **Check:** any change to the Telegram permission / relay logic preserves the
|
||||||
|
AND-intersection default-allow shape. (memory: `telegram-forum-relay-gotchas`)
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
A short PASS/FAIL table over the classes the diff touches, each FAIL with the exact
|
||||||
|
file:line and the fix. If every touched class passes, say so plainly and name the
|
||||||
|
post-deploy live check to run (not just "CI green").
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# VK Mini App / VK Games — integration reference
|
||||||
|
|
||||||
|
Captured research + our implementation map, so a future session does not need to re-fetch
|
||||||
|
the VK docs. Authoritative external source: <https://dev.vk.com/> (the `dev.vk.com` portal
|
||||||
|
does not render via plain HTTP fetch; the facts below were cross-checked against the VKCOM
|
||||||
|
reference repos cited at the end and verified against our own Go implementation).
|
||||||
|
|
||||||
|
A VK **game** is technically a **VK Mini App**: an HTML5 SPA VK loads in an **iframe inside
|
||||||
|
vk.com** (desktop + mobile web) and in a **WebView** inside the VK mobile apps (iOS/Android).
|
||||||
|
We serve our existing SPA under a dedicated `/vk/` path, mirroring the Telegram `/telegram/`
|
||||||
|
entry — the single-origin, path-routed model.
|
||||||
|
|
||||||
|
## 1. Embedding model
|
||||||
|
|
||||||
|
- VK loads the app at the **Web iframe URL** configured in the app settings (HTTPS + valid
|
||||||
|
cert required), appending the signed launch parameters as the **URL query string**.
|
||||||
|
- An optional separate **Mobile iframe URL** is used by the VK mobile apps (we use the same).
|
||||||
|
- No special `X-Frame-Options` / CSP `frame-ancestors` is required from us — VK frames the
|
||||||
|
configured origin. (Our edge sets **no** framing headers today, so VK works as-is; see the
|
||||||
|
clickjacking note in §Security.)
|
||||||
|
- URL must match the settings exactly (scheme, host, no stray `www`/whitespace).
|
||||||
|
|
||||||
|
## 2. Launch parameters (URL query)
|
||||||
|
|
||||||
|
VK appends these to the iframe `src`. The `vk_*` set is what the signature covers.
|
||||||
|
|
||||||
|
| Param | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `vk_user_id` | signed-in VK user numeric id — **the identity** |
|
||||||
|
| `vk_app_id` | our registered app id |
|
||||||
|
| `vk_is_app_user` | 0/1 — user authorized/installed the app |
|
||||||
|
| `vk_are_notifications_enabled` | 0/1 |
|
||||||
|
| `vk_language` | 2-letter UI language (`ru`, `en`, …) |
|
||||||
|
| `vk_platform` | `mobile_iphone` \| `mobile_android` \| `mobile_web` \| `desktop_web` \| … |
|
||||||
|
| `vk_ts` | unix seconds when VK generated the params |
|
||||||
|
| `vk_ref` | where the app was opened from (`catalog`, `feed`, …) |
|
||||||
|
| `vk_access_token_settings` | comma-separated granted scopes (often empty) |
|
||||||
|
| `vk_group_id`, `vk_viewer_group_role`, `vk_is_favorite`, `vk_client` | optional/contextual |
|
||||||
|
| `sign` | **the signature** (see §3) — NOT part of the signed set |
|
||||||
|
|
||||||
|
Always present: `vk_user_id`, `vk_app_id`, `vk_platform`, `vk_ts`, `sign`.
|
||||||
|
|
||||||
|
The user's **name is NOT in the launch params** (only `vk_user_id`). Read it client-side via
|
||||||
|
`VKWebAppGetUserInfo` (see §4) — unsigned, so treat it as a cosmetic display seed only.
|
||||||
|
|
||||||
|
## 3. Signature verification (`sign`) — CONFIRMED base64url, not hex
|
||||||
|
|
||||||
|
Algorithm (verified against our `gateway/internal/vkauth` + an independent Python reference):
|
||||||
|
|
||||||
|
1. Collect the query params whose key starts with `vk_` (exclude `sign`).
|
||||||
|
2. Sort by key (alphabetical).
|
||||||
|
3. Serialize as a URL-encoded query string `k=v&k=v…` (Go `url.Values.Encode()` matches VK's
|
||||||
|
reference serialization for the constrained launch-param charset).
|
||||||
|
4. `HMAC-SHA256(serialized, secret)` where `secret` = the app's **«Защищённый ключ»**
|
||||||
|
(protected / secure key, a.k.a. client_secret) from the app settings.
|
||||||
|
5. **base64url, no padding** (`+`→`-`, `/`→`_`, strip `=`).
|
||||||
|
6. Constant-time compare against `sign`.
|
||||||
|
|
||||||
|
VK launch params have **no built-in expiry** (unlike Telegram's `auth_date`). We do NOT enforce
|
||||||
|
freshness — the minted server session is the short-lived credential; a replay only
|
||||||
|
re-authenticates the same `vk_user_id`.
|
||||||
|
|
||||||
|
Verified against the official doc <https://dev.vk.com/ru/mini-apps/development/launch-params-sign>
|
||||||
|
(prose + PHP example: base64url = `strtr('+/','-_')` + `rtrim('=')`) and reproduced identically by
|
||||||
|
independent Node `crypto` + Python references. **Doc-example caveat**: that page shows secret
|
||||||
|
`wvl68m4dR1UpLrVRli` → sign `exTIBP…`, but the secret is a **placeholder** — recomputing with it does
|
||||||
|
NOT yield the shown sign (it was made with the real, unshown key). Don't chase the mismatch; our
|
||||||
|
`vkauth.Verify` is correct (`gateway/internal/vkauth/vkauth_test.go` carries cross-checked vectors,
|
||||||
|
incl. the `%2C` comma case for `vk_access_token_settings`).
|
||||||
|
|
||||||
|
## 4. VK Bridge (client SDK)
|
||||||
|
|
||||||
|
`@vkontakte/vk-bridge` (npm, v3.x; bundled — `default` export `bridge`). Methods we use / may use:
|
||||||
|
|
||||||
|
- `VKWebAppInit` — **required**: tells VK the Mini App loaded (dismisses VK's loading cover).
|
||||||
|
- `VKWebAppGetUserInfo` — `{ id, first_name, last_name, photo_200, … }`; no extra scope needed.
|
||||||
|
- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server
|
||||||
|
verification — read `window.location.search` instead, which carries `sign`).
|
||||||
|
- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API).
|
||||||
|
- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent
|
||||||
|
in the desktop VK iframe). **Used.**
|
||||||
|
- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
|
||||||
|
blocked. **Used** as the copy-code / copy-link path.
|
||||||
|
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is
|
||||||
|
"auto" (the VK webview's prefers-color-scheme does not track it). **Used.**
|
||||||
|
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used.
|
||||||
|
- `VKWebAppUpdateInsets` (+ `VKWebAppUpdateConfig`) — device safe-area insets; the app **max'es** them
|
||||||
|
with CSS `env(safe-area-inset-*)` (viewport-fit=cover) so the bottom home bar is cleared. The bridge
|
||||||
|
value is needed on Android, where the VK webview exposes no `env()` inset. **Used.**
|
||||||
|
|
||||||
|
The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so
|
||||||
|
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
|
||||||
|
it **lazily** so the pure URL helpers stay node-test-importable.
|
||||||
|
|
||||||
|
**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
|
||||||
|
`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
|
||||||
|
`vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
|
||||||
|
`vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
|
||||||
|
and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
|
||||||
|
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
|
||||||
|
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
|
||||||
|
channel (e.g. an invite API) ever delivers a payload.
|
||||||
|
|
||||||
|
## 5. Test mode (to verify before moderation)
|
||||||
|
|
||||||
|
1. App already registered (we have the App ID).
|
||||||
|
2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=<id>`):
|
||||||
|
- Category = **Игра** (Game).
|
||||||
|
- **Web iframe URL** = our public HTTPS `/vk/` (the test-contour origin for contour testing,
|
||||||
|
prod `https://erudit-game.ru/vk/` later). Mobile iframe URL = same.
|
||||||
|
- Copy the **«Защищённый ключ»** → set as `GATEWAY_VK_APP_SECRET` (Gitea `TEST_`/`PROD_` secret).
|
||||||
|
- Add own VK id to **testers**; open in test mode.
|
||||||
|
3. Test mode = visible only to admins/testers, no payments processed.
|
||||||
|
|
||||||
|
## 6. Auth / identity (our model)
|
||||||
|
|
||||||
|
- `vk_user_id` (from verified params) → backend identity `kind='vk'`, `external_id=vk_user_id`,
|
||||||
|
auto-confirmed (a platform identity). First contact seeds language from `vk_language` and the
|
||||||
|
display name from the client-supplied `VKWebAppGetUserInfo` name (placeholder if empty).
|
||||||
|
- No VK access token / VK API call needed for the launch+login MVP.
|
||||||
|
|
||||||
|
## 7. Payments / monetization
|
||||||
|
|
||||||
|
VK Pay / «голоса» (votes) are **optional**, not required to publish a free game. Not planned.
|
||||||
|
|
||||||
|
## 8. ToS / moderation (pre-publish, analyzed — no blocker for a free «Эрудит»)
|
||||||
|
|
||||||
|
- **Trademark**: "Scrabble" is trademarked. Our public brand is **«Эрудит»** (erudit-game.ru),
|
||||||
|
a generic Russian word-game name → fine. Ensure the VK-registered app name is «Эрудит»/word-game,
|
||||||
|
NOT "Scrabble". The repo name is internal and irrelevant to moderation.
|
||||||
|
- **Pre-publish requirements**: public **Privacy Policy** + **ToS** URLs (disclose collected data:
|
||||||
|
`vk_user_id`, language; mention VK), **age rating** (likely 6+/12+), icon, description.
|
||||||
|
- **Dictionary**: standard word lists; VK may expect offensive-word filtering — likely fine for a
|
||||||
|
dictionary game, flag if moderation asks.
|
||||||
|
- **In-game chat (UGC)**: we already have a moderated chat + support relay → covered.
|
||||||
|
- Moderation reviews after submission (commonly ~24–72h); rejects on violence/hate/sexual/illegal
|
||||||
|
content or IP infringement — none apply.
|
||||||
|
|
||||||
|
## 9. Platforms
|
||||||
|
|
||||||
|
Desktop web (iframe), mobile web (iframe), VK iOS app (WKWebView), VK Android app (WebView). Bridge
|
||||||
|
methods behave per-platform; the app's own back chevron + app-shell document-pin cover navigation
|
||||||
|
without VK-specific code. Theme/viewport fine-tuning is best verified live in the real VK client
|
||||||
|
(not reproducible in Playwright — like the iOS gesture caveats).
|
||||||
|
|
||||||
|
## 10. Our implementation map (what to touch for VK)
|
||||||
|
|
||||||
|
- **Wire**: `pkg/fbs/scrabble.fbs` → `VKLoginRequest{ params, browser_tz, display_name }`
|
||||||
|
(regen: `make -C pkg fbs` + `pnpm -C ui codegen`).
|
||||||
|
- **Gateway**: `internal/vkauth/` (the §3 verify), `internal/transcode` op `auth.vk`
|
||||||
|
(registered via `WithVKAuth(secret)` option; `DomainCode` → `invalid_vk_params`),
|
||||||
|
`internal/backendclient` `VKAuth` → `POST /api/v1/internal/sessions/vk`,
|
||||||
|
config `GATEWAY_VK_APP_SECRET`, SPA mount `/vk/` in `internal/connectsrv/server.go`.
|
||||||
|
- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform
|
||||||
|
kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration
|
||||||
|
`00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract).
|
||||||
|
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/
|
||||||
|
`vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
|
||||||
|
and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
|
||||||
|
`encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
|
||||||
|
`Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
|
||||||
|
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
|
||||||
|
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
|
||||||
|
(`PROD_…` secret, deploy-main).
|
||||||
|
- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
|
||||||
|
existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
|
||||||
|
the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- VKCOM/vk-bridge — <https://github.com/VKCOM/vk-bridge>
|
||||||
|
- VKCOM/vk-apps-launch-params (canonical signature examples) — <https://github.com/VKCOM/vk-apps-launch-params>
|
||||||
|
- kravetsone/vk-launch-params — <https://github.com/kravetsone/vk-launch-params>
|
||||||
|
- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — <https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/vkapps>
|
||||||
|
- VK Mini Apps API — <https://github.com/VKCOM/vk-mini-apps-api>
|
||||||
+103
-3
@@ -31,7 +31,7 @@ on:
|
|||||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.3.0
|
DICT_VERSION: v1.3.1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
@@ -73,6 +73,9 @@ jobs:
|
|||||||
go=false; ui=false
|
go=false; ui=false
|
||||||
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
||||||
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
||||||
|
# The render sidecar bundles ui/src/lib, so its dir rides the ui lane (the
|
||||||
|
# deploy's compose build picks it up either way).
|
||||||
|
if echo "$files" | grep -qE '^renderer/'; then ui=true; fi
|
||||||
# A workflow or deploy change re-runs everything as a safety net.
|
# A workflow or deploy change re-runs everything as a safety net.
|
||||||
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
||||||
else
|
else
|
||||||
@@ -199,6 +202,14 @@ jobs:
|
|||||||
- name: Bundle-size budget
|
- name: Bundle-size budget
|
||||||
run: node scripts/bundle-size.mjs
|
run: node scripts/bundle-size.mjs
|
||||||
|
|
||||||
|
# The render sidecar executes the shared ui/src/lib/gameimage.ts on skia-canvas;
|
||||||
|
# its smoke test guards the bundling + skia seam (docs/TESTING.md).
|
||||||
|
- name: Render sidecar test
|
||||||
|
working-directory: renderer
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm test
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: pnpm exec playwright install chromium webkit
|
run: pnpm exec playwright install chromium webkit
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -207,11 +218,66 @@ jobs:
|
|||||||
run: pnpm run test:e2e
|
run: pnpm run test:e2e
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
# conformance proves the client's local move preview (the ported dawg reader +
|
||||||
|
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
||||||
|
# a Go step generates golden parity vectors from the release dictionaries, then the
|
||||||
|
# gated Vitest suite replays them. It spans both toolchains, so it runs whenever the
|
||||||
|
# Go engine side or the UI side changed.
|
||||||
|
conformance:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GOPRIVATE: gitea.iliadenisov.ru/*
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Fetch dictionary DAWGs
|
||||||
|
run: |
|
||||||
|
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||||
|
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Generate golden parity vectors
|
||||||
|
run: |
|
||||||
|
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
||||||
|
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@11.0.9
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ui
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Local-eval conformance (reader + validator vs the Go engine)
|
||||||
|
working-directory: ui
|
||||||
|
env:
|
||||||
|
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||||
|
DICT_GOLD_DIR: /tmp/dictgold
|
||||||
|
DICT_VALID_DIR: /tmp/validgold
|
||||||
|
run: pnpm exec vitest run src/lib/dict/
|
||||||
|
|
||||||
# gate is the single branch-protection required check. It always runs and passes
|
# gate is the single branch-protection required check. It always runs and passes
|
||||||
# only when each upstream job succeeded or was skipped (a path-filtered no-op),
|
# only when each upstream job succeeded or was skipped (a path-filtered no-op),
|
||||||
# failing the merge if any actually failed or was cancelled.
|
# failing the merge if any actually failed or was cancelled.
|
||||||
gate:
|
gate:
|
||||||
needs: [unit, integration, ui]
|
needs: [unit, integration, ui, conformance]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -221,7 +287,7 @@ jobs:
|
|||||||
- name: Aggregate required checks
|
- name: Aggregate required checks
|
||||||
run: |
|
run: |
|
||||||
fail=
|
fail=
|
||||||
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do
|
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}" "conformance:${{ needs.conformance.result }}"; do
|
||||||
name="${r%%:*}"; res="${r#*:}"
|
name="${r%%:*}"; res="${r#*:}"
|
||||||
echo "$name = $res"
|
echo "$name = $res"
|
||||||
case "$res" in
|
case "$res" in
|
||||||
@@ -261,6 +327,22 @@ jobs:
|
|||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
||||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||||
|
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
||||||
|
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
||||||
|
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||||
|
# Transactional email via the shared Selectel relay. Empty host leaves the
|
||||||
|
# backend on the log mailer (email disabled) but the contour still boots.
|
||||||
|
SMTP_RELAY_USER: ${{ secrets.TEST_SMTP_RELAY_USER }}
|
||||||
|
SMTP_RELAY_PASS: ${{ secrets.TEST_SMTP_RELAY_PASS }}
|
||||||
|
SMTP_RELAY_HOST: ${{ vars.TEST_SMTP_RELAY_HOST }}
|
||||||
|
SMTP_RELAY_PORT: ${{ vars.TEST_SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.TEST_SMTP_RELAY_TLS }}
|
||||||
|
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
||||||
|
# Canonical public origin for links in the email (this contour's URL);
|
||||||
|
# required by the backend whenever SMTP_RELAY_HOST is set.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
@@ -277,6 +359,7 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
|
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
|
||||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
@@ -337,6 +420,23 @@ jobs:
|
|||||||
docker logs --tail 50 scrabble-backend || true
|
docker logs --tail 50 scrabble-backend || true
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
- name: Probe the /dict edge route reaches the gateway
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
# The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version}
|
||||||
|
# for the local move preview. If caddy does not route /dict to the gateway the request
|
||||||
|
# falls to the static landing and the client silently gets a non-dawg blob. Probed
|
||||||
|
# unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a
|
||||||
|
# 404/200 from the landing catch-all.
|
||||||
|
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)"
|
||||||
|
echo "$out" | grep -E "HTTP/" || true
|
||||||
|
if echo "$out" | grep -q " 401"; then
|
||||||
|
echo "ok: /dict reaches the gateway (401 unauthenticated)"
|
||||||
|
else
|
||||||
|
echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Probe the Telegram validator and bot liveness
|
- name: Probe the Telegram validator and bot liveness
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
|
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
|
||||||
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
||||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
@@ -58,10 +59,10 @@ jobs:
|
|||||||
working-directory: deploy
|
working-directory: deploy
|
||||||
run: |
|
run: |
|
||||||
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
||||||
# The four main-stack images via compose (reuses the build args, incl. VERSION);
|
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
||||||
# the bot separately, since it is profiled out of the prod compose.
|
# the bot separately, since it is profiled out of the prod compose.
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator renderer
|
||||||
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
|
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
|
||||||
docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
|
docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
|
||||||
|
|
||||||
@@ -82,6 +83,17 @@ jobs:
|
|||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
||||||
|
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
|
# Transactional email via the shared Selectel relay (confirm-codes).
|
||||||
|
SMTP_RELAY_USER: ${{ secrets.PROD_SMTP_RELAY_USER }}
|
||||||
|
SMTP_RELAY_PASS: ${{ secrets.PROD_SMTP_RELAY_PASS }}
|
||||||
|
SMTP_RELAY_HOST: ${{ vars.PROD_SMTP_RELAY_HOST }}
|
||||||
|
SMTP_RELAY_PORT: ${{ vars.PROD_SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.PROD_SMTP_RELAY_TLS }}
|
||||||
|
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
@@ -136,6 +148,15 @@ jobs:
|
|||||||
export APP_VERSION='$TAG'
|
export APP_VERSION='$TAG'
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
|
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
||||||
|
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
|
||||||
|
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
|
||||||
|
export SMTP_RELAY_TLS='$SMTP_RELAY_TLS'
|
||||||
|
export SMTP_RELAY_USER='$SMTP_RELAY_USER'
|
||||||
|
export SMTP_RELAY_PASS='$SMTP_RELAY_PASS'
|
||||||
|
export SMTP_RELAY_FROM='$SMTP_RELAY_FROM'
|
||||||
|
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||||
EOF
|
EOF
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
|
|||||||
@@ -125,9 +125,10 @@ gateway/ # module scrabble/gateway: Connect-RPC edge, embeds
|
|||||||
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
||||||
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
||||||
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
||||||
|
renderer/ # image-render sidecar (Node + skia-canvas): runs ui/src/lib/gameimage.ts server-side for the finished-game PNG export
|
||||||
loadtest/ # module scrabble/loadtest: the load/stress harness
|
loadtest/ # module scrabble/loadtest: the load/stress harness
|
||||||
docs/ .gitea/workflows/ CLAUDE.md README.md
|
docs/ .gitea/workflows/ CLAUDE.md README.md
|
||||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile renderer/Dockerfile # multi-stage distroless (renderer: node:22-slim + skia-canvas + fonts); gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
||||||
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
|
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -143,6 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
|||||||
|
|
||||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
||||||
pnpm start # UI mock mode: lobby -> game, no backend
|
pnpm start # UI mock mode: lobby -> game, no backend
|
||||||
|
cd renderer && pnpm install && pnpm test # image-render sidecar (bundles ui/src/lib/gameimage.ts, skia smoke)
|
||||||
|
|
||||||
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
||||||
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Erudit — site icons + Open Graph card
|
||||||
|
|
||||||
|
The favicon set and the `og:image` link-preview card for the public landing
|
||||||
|
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
|
||||||
|
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
|
||||||
|
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
|
||||||
|
|
||||||
|
| Output (committed to `ui/public/`) | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
|
||||||
|
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
|
||||||
|
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
|
||||||
|
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
|
||||||
|
character) from LiberationSans (Arial-metric, the game's font stack) with their
|
||||||
|
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
|
||||||
|
plain SVG from those outlines — no font is needed at generation time — writes
|
||||||
|
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
|
||||||
|
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
|
||||||
|
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
|
||||||
|
installed chromium version; the SVG sources are deterministic.
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
|
||||||
|
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
|
||||||
|
needs no extra packages**.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons
|
||||||
|
|
||||||
|
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
|
||||||
|
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
||||||
|
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
||||||
|
|
||||||
|
# 2. regenerate everything in ui/public/:
|
||||||
|
node build/generate.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
// Extract the glyph outlines the site icons and the og-image wordmark need from a
|
||||||
|
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
|
||||||
|
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
|
||||||
|
// plus the advance width so generate.js can lay out words without the font.
|
||||||
|
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
|
||||||
|
const opentype = require('opentype.js');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
|
||||||
|
const b = fs.readFileSync(FONT);
|
||||||
|
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
||||||
|
const FS = 1000; // em scale
|
||||||
|
|
||||||
|
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
|
||||||
|
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
|
||||||
|
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
|
||||||
|
|
||||||
|
function glyphData(ch) {
|
||||||
|
const g = font.charToGlyph(ch);
|
||||||
|
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
|
||||||
|
const contours = [];
|
||||||
|
let cur = null, prev = null;
|
||||||
|
for (const c of p.commands) {
|
||||||
|
if (c.type === 'M') {
|
||||||
|
if (cur) contours.push(cur);
|
||||||
|
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'L') {
|
||||||
|
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'C') {
|
||||||
|
cur[cur.length - 1].o = [c.x1, c.y1];
|
||||||
|
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'Q') {
|
||||||
|
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
|
||||||
|
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
|
||||||
|
cur[cur.length - 1].o = c1;
|
||||||
|
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'Z') {
|
||||||
|
if (cur && cur.length > 1) {
|
||||||
|
const last = cur[cur.length - 1], first = cur[0];
|
||||||
|
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
|
||||||
|
first.i = last.i; // fold the duplicate closing point into the first
|
||||||
|
cur.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) { contours.push(cur); cur = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) contours.push(cur);
|
||||||
|
|
||||||
|
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
||||||
|
const out = contours.map(ct => {
|
||||||
|
const v = [], i = [], o = [];
|
||||||
|
ct.forEach(pt => {
|
||||||
|
v.push(pt.v);
|
||||||
|
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
|
||||||
|
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
|
||||||
|
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
|
||||||
|
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
|
||||||
|
});
|
||||||
|
return { i, o, v, c: true };
|
||||||
|
});
|
||||||
|
const bbox = out.length
|
||||||
|
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
|
||||||
|
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
|
||||||
|
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
const glyphs = {};
|
||||||
|
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
|
||||||
|
|
||||||
|
const out = __dirname + '/glyphs.json';
|
||||||
|
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
|
||||||
|
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use strict';
|
||||||
|
// Site icons + the Open Graph card for the public landing, drawn from the same
|
||||||
|
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
|
||||||
|
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
|
||||||
|
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
|
||||||
|
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
|
||||||
|
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
|
||||||
|
// ui/public/:
|
||||||
|
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
|
||||||
|
// favicon.ico 32x32 PNG-in-ICO render of the same
|
||||||
|
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
|
||||||
|
// og-image.png 1200x630 card: tile + wordmark on the board green
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
|
||||||
|
const UI = path.resolve(__dirname, '../../../ui');
|
||||||
|
const OUT = path.join(UI, 'public');
|
||||||
|
|
||||||
|
// ---- palette (vk loader tile + app.css board tokens) ------------------------
|
||||||
|
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
|
||||||
|
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
|
||||||
|
|
||||||
|
// ---- glyph outlines -> SVG path data ----------------------------------------
|
||||||
|
const r2 = n => Math.round(n * 100) / 100;
|
||||||
|
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
|
||||||
|
function pathD(g, sc, tx, ty) {
|
||||||
|
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
|
||||||
|
const Z = [0, 0];
|
||||||
|
return g.contours.map(ct => {
|
||||||
|
const n = ct.v.length;
|
||||||
|
let d = `M${pt(ct.v[0], Z)}`;
|
||||||
|
for (let k = 1; k <= n; k++) {
|
||||||
|
const a = k - 1, b = k % n;
|
||||||
|
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
|
||||||
|
}
|
||||||
|
return d + 'Z';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
|
||||||
|
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
|
||||||
|
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
|
||||||
|
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
|
||||||
|
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
|
||||||
|
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
|
||||||
|
}
|
||||||
|
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
|
||||||
|
// the capital height (measured on «Э»). Returns the combined path + the width.
|
||||||
|
function textLine(str, capPx, x, y, colour) {
|
||||||
|
const sc = capPx / G.glyphs['Э'].bbox.h;
|
||||||
|
let d = '', w = 0;
|
||||||
|
for (const ch of str) {
|
||||||
|
const g = G.glyphs[ch];
|
||||||
|
if (g.contours.length) d += pathD(g, sc, x + w, y);
|
||||||
|
w += g.adv * sc;
|
||||||
|
}
|
||||||
|
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
|
||||||
|
}
|
||||||
|
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
|
||||||
|
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
|
||||||
|
function tile(cx, cy, size, withScore) {
|
||||||
|
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
|
||||||
|
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
|
||||||
|
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
|
||||||
|
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
||||||
|
|
||||||
|
// ---- favicon.svg (the committed vector master) -------------------------------
|
||||||
|
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
|
||||||
|
|
||||||
|
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
|
||||||
|
const appleTouch = svg(180, 180,
|
||||||
|
`<rect width="180" height="180" fill="${FACE}"/>` +
|
||||||
|
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
|
||||||
|
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
|
||||||
|
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
|
||||||
|
|
||||||
|
// ---- og-image: tile + wordmark, centred as one group on the board green ------
|
||||||
|
function ogImage() {
|
||||||
|
const W = 1200, H = 630, tileSize = 340, gap = 84;
|
||||||
|
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
|
||||||
|
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
|
||||||
|
const textW = Math.max(l1.width, l2.width);
|
||||||
|
const left = (W - (tileSize + gap + textW)) / 2;
|
||||||
|
const tx = left + tileSize + gap;
|
||||||
|
// Two baselines around the vertical centre; the tile centre sits between them.
|
||||||
|
const b1 = 295, b2 = 408;
|
||||||
|
const body =
|
||||||
|
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
|
||||||
|
tile(left + tileSize / 2, H / 2, tileSize, true) +
|
||||||
|
textLine('Эрудит', 112, tx, b1, TEXT).svg +
|
||||||
|
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
|
||||||
|
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
|
||||||
|
return svg(W, H, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
|
||||||
|
async function shoot(page, markup, w, h, transparent) {
|
||||||
|
await page.setViewportSize({ width: w, height: h });
|
||||||
|
await page.setContent(`<body style="margin:0">${markup}</body>`);
|
||||||
|
return page.screenshot({ omitBackground: transparent });
|
||||||
|
}
|
||||||
|
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
|
||||||
|
function icoFromPNG(png, sizePx) {
|
||||||
|
const h = Buffer.alloc(22);
|
||||||
|
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
|
||||||
|
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
|
||||||
|
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
|
||||||
|
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
|
||||||
|
return Buffer.concat([h, png]);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
|
||||||
|
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
|
||||||
|
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
|
||||||
|
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
|
||||||
|
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
|
||||||
|
await browser.close();
|
||||||
|
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
|
||||||
|
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
|
||||||
|
}
|
||||||
|
})();
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,115 @@
|
|||||||
|
# Erudit — VK loading-screen logo (Lottie)
|
||||||
|
|
||||||
|
Animated logo for the **VK app loading screen** (shown before the SPA assets load).
|
||||||
|
The Erudit «Э» tile (score `8`) **drops in under gravity, lands with a soft squash —
|
||||||
|
its left/right edges bulging into a cushion — then springs back up**, looping. A light
|
||||||
|
"glint" is caught at the moment of the bounce.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `erudit-loader.json` | The Lottie to upload to VK. |
|
||||||
|
| `erudit-loader-preview.gif` | Looping preview. Rendered on a green "baize" background **only in the preview** — the real asset has a **transparent** background. |
|
||||||
|
| `build/` | The reproducible build pipeline (see *Regenerate*). |
|
||||||
|
|
||||||
|
**VK requirements met:** 96×96 px · vector Lottie JSON · ≤ 24 KB (~11 KB) · seamless
|
||||||
|
~1.1 s loop · transparent background.
|
||||||
|
|
||||||
|
Design reference: a photo of the physical wooden Erudit tile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
A single flat layer holds the tile — a rounded-rect **face path**, the two glyphs, and
|
||||||
|
a thin border — and everything is driven by that layer's transform plus a few colour /
|
||||||
|
shape tracks. The anchor sits on the tile's **bottom edge**, so the squash happens
|
||||||
|
against the floor.
|
||||||
|
|
||||||
|
### Bounce (gravity)
|
||||||
|
`position.y` of the bottom edge goes apex → floor → apex with **no dwell** (the apex is
|
||||||
|
an instantaneous turn-around). The fall uses a strong **ease-in** (accelerate) and the
|
||||||
|
rise a strong **ease-out** (decelerate), so it reads as real gravity. While falling fast
|
||||||
|
the tile slightly **stretches** (tall+thin); that is the squash-and-stretch setup.
|
||||||
|
|
||||||
|
### Soft cushion (squash)
|
||||||
|
On contact the layer **squashes** (scaleY↓, scaleX↑) about the bottom anchor, with a
|
||||||
|
touch of skew. Crucially the squash/cushion is **decoupled from the fall speed** — it
|
||||||
|
eases in and out *slowly* (`ES`), so the landing feels soft even though the fall is
|
||||||
|
fast. The squash is deliberately gentle (≈ 86 % / 112 %).
|
||||||
|
|
||||||
|
### The cushion shape (the hard part)
|
||||||
|
The face is **not** a plain rounded rect — it is a path whose left/right contour bows
|
||||||
|
out into a convex cushion on impact:
|
||||||
|
- the rest rounded-rect outline is sampled (fine corner arcs + edge mids), and on impact
|
||||||
|
every point is pushed outward by a smooth **barrel** profile `f(y) = b·(1 − (y/HH)²)`
|
||||||
|
(max at the middle, fading to zero at the top/bottom);
|
||||||
|
- so the **whole side — corners included — bows out as one piece**, never just the
|
||||||
|
straight middle;
|
||||||
|
- bezier handles are computed as a **chordal spline** (handle length ∝ the adjacent
|
||||||
|
chord), which stays smooth across the uneven corner/edge point spacing. This is what
|
||||||
|
keeps the corner↔cushion junction kink-free — both at rest and fully bulged — which a
|
||||||
|
naïve uniform Catmull-Rom (or moving discrete points with fixed tangents) does not.
|
||||||
|
|
||||||
|
### Glint
|
||||||
|
At the bounce the face flashes a little lighter (`FACE_GLINT`) and the glyphs warm
|
||||||
|
toward brown-red (`BLACK_LIT`), then settle back. A cheap, warm "catch the light".
|
||||||
|
|
||||||
|
### Border & background
|
||||||
|
The tile carries a thin static **border** a touch darker than the face (`BORDER`) so it
|
||||||
|
reads on any background. The **green baize background is added only when rendering the
|
||||||
|
preview GIF** — the Lottie itself is transparent.
|
||||||
|
|
||||||
|
### Player compatibility
|
||||||
|
Only plain 2-D shapes (`ddd:0`) — no 3-D layers, expressions or effects — so every
|
||||||
|
Lottie player (lottie-web on the web, rlottie on native) renders it identically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Glyphs
|
||||||
|
|
||||||
|
`Э` (U+042D) and `8` are **real outlines from LiberationSans-Regular** — metric-
|
||||||
|
compatible with Arial, matching the game's `--font: system-ui … Arial` stack
|
||||||
|
(see `ui/src/app.css`). `build/extract.js` pulls the contours into `build/glyphs.json`
|
||||||
|
as Lottie cubic paths; a hair of same-colour stroke adds a touch of weight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
Requirements: Node ≥ 18. `extract.js` additionally needs `opentype.js`
|
||||||
|
(`npm i opentype.js`); **`generate.js` has no dependencies**.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/vk
|
||||||
|
|
||||||
|
# 1. (optional) re-extract the glyphs — only if you change the font:
|
||||||
|
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
||||||
|
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
||||||
|
|
||||||
|
# 2. build the animation (reads build/glyphs.json):
|
||||||
|
node build/generate.js erudit-loader.json # -> erudit-loader.json
|
||||||
|
|
||||||
|
# 3. (optional) live preview — needs lottie-web (npm i lottie-web):
|
||||||
|
node build/build-preview.js erudit-loader.json preview.html
|
||||||
|
# then open preview.html in a browser.
|
||||||
|
```
|
||||||
|
|
||||||
|
The preview GIF is assembled by rendering the Lottie frame-by-frame (lottie-web canvas,
|
||||||
|
filled with the green baize) and stitching with `ffmpeg`; the live HTML preview is the
|
||||||
|
quickest way to eyeball changes.
|
||||||
|
|
||||||
|
## Tunables (top of `build/generate.js`)
|
||||||
|
|
||||||
|
| Symbol | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `OP`, `FR` | loop length (frames) / fps — overall speed |
|
||||||
|
| `T_HIT / T_PEAK / T_LIFT / T_REC` | beats: contact / squash peak / lift-off / cushion recovered |
|
||||||
|
| `EI / EO / ES` | easings: fast fall / fast rise / slow soft cushion |
|
||||||
|
| `APEX`, `FLOOR` | bottom-edge screen-y at the top of the bounce / at rest |
|
||||||
|
| `HW`, `HH`, `CR` | tile half-width / half-height / corner radius |
|
||||||
|
| `scl` squash values | the squash amount (scaleX↑ / scaleY↓) |
|
||||||
|
| `bulge` `b` | cushion depth (how far the sides bow out) |
|
||||||
|
| `FACE / FACE_GLINT` | wood face / glint flash |
|
||||||
|
| `BORDER` | tile rim colour |
|
||||||
|
| `BLACK / BLACK_LIT` | glyph / glyph-at-glint (brown-red) |
|
||||||
|
| preview bg `#3C7858` | the green-baize colour used **only** in the GIF |
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const data = fs.readFileSync(process.argv[2] || 'erudit.json', 'utf8');
|
||||||
|
const lottiePath = __dirname + '/node_modules/lottie-web/build/player/lottie.min.js';
|
||||||
|
const html = `<!doctype html><html><head><meta charset="utf8"><style>
|
||||||
|
body{margin:0;background:#2b2b2b;font-family:sans-serif;color:#ccc}
|
||||||
|
.row{display:flex;gap:18px;padding:18px;align-items:flex-end;flex-wrap:wrap}
|
||||||
|
.cell{text-align:center}
|
||||||
|
.chk{background-image:linear-gradient(45deg,#8a8a8a 25%,transparent 25%),linear-gradient(-45deg,#8a8a8a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#8a8a8a 75%),linear-gradient(-45deg,transparent 75%,#8a8a8a 75%);background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;background-color:#b5b5b5}
|
||||||
|
.s96{width:96px;height:96px}.big{width:336px;height:336px}small{font-size:11px}
|
||||||
|
</style></head><body><div class="row" id="row"></div>
|
||||||
|
<script src="./lottie.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const animationData=${data};window.AD=animationData;
|
||||||
|
const row=document.getElementById('row');window.anims=[];
|
||||||
|
function make(cls,frame,label){
|
||||||
|
const cell=document.createElement('div');cell.className='cell';
|
||||||
|
const box=document.createElement('div');box.className='chk '+cls;cell.appendChild(box);
|
||||||
|
cell.appendChild(document.createElement('br'));
|
||||||
|
const cap=document.createElement('small');cap.textContent=label;cell.appendChild(cap);
|
||||||
|
row.appendChild(cell);
|
||||||
|
const a=lottie.loadAnimation({container:box,renderer:'svg',loop:false,autoplay:false,animationData:JSON.parse(JSON.stringify(animationData))});
|
||||||
|
a.addEventListener('DOMLoaded',()=>a.goToAndStop(frame,true));
|
||||||
|
window.anims.push(a);
|
||||||
|
}
|
||||||
|
[0,22,45,68].forEach(f=>make('s96',f,'f'+f));
|
||||||
|
make('big',22,'f22 x3.5');
|
||||||
|
window.__ready=true;
|
||||||
|
</script></body></html>`;
|
||||||
|
fs.writeFileSync(process.argv[3] || 'preview.html', html);
|
||||||
|
console.log('wrote', process.argv[3] || 'preview.html');
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
// Extract the Cyrillic "Э" (U+042D) and the digit "8" outlines from a grotesque
|
||||||
|
// font (LiberationSans = Arial-metric, matching the game's system-ui/Arial stack)
|
||||||
|
// and emit Lottie cubic-bezier contours, centred at the origin, y-down.
|
||||||
|
const opentype = require('opentype.js');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
|
||||||
|
const b = fs.readFileSync(FONT);
|
||||||
|
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
||||||
|
const FS = 1000; // em scale
|
||||||
|
|
||||||
|
function glyphContours(ch) {
|
||||||
|
const p = font.charToGlyph(ch).getPath(0, 0, FS); // baseline at y=0, y-down
|
||||||
|
const contours = [];
|
||||||
|
let cur = null, prev = null;
|
||||||
|
for (const c of p.commands) {
|
||||||
|
if (c.type === 'M') {
|
||||||
|
if (cur) contours.push(cur);
|
||||||
|
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'L') {
|
||||||
|
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'C') {
|
||||||
|
cur[cur.length - 1].o = [c.x1, c.y1];
|
||||||
|
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'Q') {
|
||||||
|
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
|
||||||
|
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
|
||||||
|
cur[cur.length - 1].o = c1;
|
||||||
|
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'Z') {
|
||||||
|
if (cur && cur.length > 1) {
|
||||||
|
const last = cur[cur.length - 1], first = cur[0];
|
||||||
|
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
|
||||||
|
first.i = last.i; // fold the duplicate closing point into the first
|
||||||
|
cur.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) { contours.push(cur); cur = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) contours.push(cur);
|
||||||
|
|
||||||
|
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
||||||
|
const out = contours.map(ct => {
|
||||||
|
const v = [], i = [], o = [];
|
||||||
|
ct.forEach(pt => {
|
||||||
|
v.push(pt.v);
|
||||||
|
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
|
||||||
|
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
|
||||||
|
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
|
||||||
|
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
|
||||||
|
});
|
||||||
|
return { i, o, v, c: true };
|
||||||
|
});
|
||||||
|
return { contours: out, bbox: { x: minx, y: miny, w: maxx - minx, h: maxy - miny } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const E = glyphContours('Э');
|
||||||
|
const D8 = glyphContours('8');
|
||||||
|
fs.writeFileSync('glyphs.json', JSON.stringify({ em: FS, E, D8 }));
|
||||||
|
console.log('Э bbox', E.bbox, 'contours', E.contours.map(c => c.v.length));
|
||||||
|
console.log('8 bbox', D8.bbox, 'contours', D8.contours.map(c => c.v.length));
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use strict';
|
||||||
|
// VK preloader Lottie: a flat wooden Erudit tile ("Э" + score "8") that drops in
|
||||||
|
// under gravity, squashes on impact (convex cushion bulging out the left/right edges
|
||||||
|
// + a touch of skew), and springs back up — looping. A light "glint" is caught at the
|
||||||
|
// moment of the bounce. Pure 2D shapes (ddd:0). Glyphs are real LiberationSans
|
||||||
|
// outlines (Arial-metric, = the game's font stack); see extract.js -> glyphs.json.
|
||||||
|
const fs = require('fs');
|
||||||
|
const G = JSON.parse(fs.readFileSync(__dirname + '/glyphs.json', 'utf8'));
|
||||||
|
|
||||||
|
// ---- canvas / timing -------------------------------------------------------
|
||||||
|
const W = 96, H = 96, cx = 48;
|
||||||
|
const FR = 30, OP = 34; // ~1.13 s loop (dynamic, 25% faster)
|
||||||
|
// keyframe beats (frames): fast fall -> soft squash -> lift -> fast rise -> apex (no dwell)
|
||||||
|
const T_HIT = 13, T_PEAK = 18, T_LIFT = 19, T_REC = 28;
|
||||||
|
|
||||||
|
// ---- geometry --------------------------------------------------------------
|
||||||
|
const HW = 26, HH = 26, CR = 6; // tile half-width / half-height / corner radius
|
||||||
|
const FLOOR = 90, APEX = 60; // bottom-centre screen-y at rest / at the top of the bounce
|
||||||
|
|
||||||
|
// ---- palette ---------------------------------------------------------------
|
||||||
|
const col = h => [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255];
|
||||||
|
const FACE = col('#D9B978'); // wood face (flat)
|
||||||
|
const FACE_GLINT = col('#E7CD95'); // face flash caught on impact (gentle, not blinding)
|
||||||
|
const BORDER = col('#B49559'); // tile rim: a touch darker than the face, reads on any bg
|
||||||
|
const BLACK = col('#1A1A1A');
|
||||||
|
const BLACK_LIT = col('#421A0B'); // glyph warms to brown-red on the glint
|
||||||
|
const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
||||||
|
|
||||||
|
// ---- property / easing helpers ---------------------------------------------
|
||||||
|
const still = v => ({ a: 0, k: v });
|
||||||
|
const EI = { o: { x: [0.82], y: [0] }, i: { x: [1], y: [1] } }; // strong accelerate (gravity fall)
|
||||||
|
const EO = { o: { x: [0], y: [0] }, i: { x: [0.18], y: [1] } }; // strong decelerate (rise to apex)
|
||||||
|
const ES = { o: { x: [0.42], y: [0] }, i: { x: [0.58], y: [1] } }; // soft/slow (the cushion)
|
||||||
|
function anim(samples) { // samples: {t, v, e?} ; e = easing toward the NEXT key
|
||||||
|
return { a: 1, k: samples.map((s, idx) => {
|
||||||
|
const kf = { t: s.t, s: s.v };
|
||||||
|
if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; }
|
||||||
|
return kf;
|
||||||
|
}) };
|
||||||
|
}
|
||||||
|
const animColor = s => anim(s.map(x => ({ t: x.t, v: [...x.v, 1], e: x.e })));
|
||||||
|
|
||||||
|
// ---- shape helpers ---------------------------------------------------------
|
||||||
|
const tr = () => ({ ty: 'tr', p: still([0,0]), a: still([0,0]), s: still([100,100]), r: still(0), o: still(100) });
|
||||||
|
const grp = (items, nm) => ({ ty: 'gr', it: [...items, tr()], nm });
|
||||||
|
const fill = c => ({ ty: 'fl', c, o: still(100), r: 1, bm: 0 });
|
||||||
|
const stroke = (c,w) => ({ ty: 'st', c: still(c), o: still(100), w: still(w), lc: 2, lj: 2, ml: 4, bm: 0 });
|
||||||
|
function glyph(gd, hpx, px, py, bold, colour, nm) { // font glyph -> filled+stroked group
|
||||||
|
const sc = hpx / gd.bbox.h;
|
||||||
|
const bcx = gd.bbox.x + gd.bbox.w / 2, bcy = gd.bbox.y + gd.bbox.h / 2;
|
||||||
|
const shapes = gd.contours.map(ct => ({
|
||||||
|
ty: 'sh', d: 1, ks: still({
|
||||||
|
i: ct.i.map(p => [p[0]*sc, p[1]*sc]), o: ct.o.map(p => [p[0]*sc, p[1]*sc]),
|
||||||
|
v: ct.v.map(p => [(p[0]-bcx)*sc + px, (p[1]-bcy)*sc + py]), c: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return grp([...shapes, fill(colour), stroke(BLACK, bold)], nm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample the rest rounded-rect outline (clockwise): fine corner arcs + one mid point
|
||||||
|
// per straight edge, so a smooth interpolant reproduces it cleanly.
|
||||||
|
function arc(ccx, ccy, a0, a1, n) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < n; i++) { const a = (a0 + (a1 - a0) * i / (n - 1)) * Math.PI / 180; out.push([ccx + CR*Math.cos(a), ccy + CR*Math.sin(a)]); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const REST = (() => {
|
||||||
|
const NC = 7, yi = HH - CR, xi = HW - CR;
|
||||||
|
const C = [ arc(xi,-yi,-90,0,NC), arc(xi,yi,0,90,NC), arc(-xi,yi,90,180,NC), arc(-xi,-yi,180,270,NC) ];
|
||||||
|
const pts = [];
|
||||||
|
for (let k = 0; k < 4; k++) {
|
||||||
|
pts.push(...C[k]);
|
||||||
|
const a = C[k][NC-1], b = C[(k+1)%4][0];
|
||||||
|
pts.push([(a[0]+b[0])/2, (a[1]+b[1])/2]); // straight-edge mid point (keeps the edge straight)
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
})();
|
||||||
|
// The whole left/right contour (corners + side) bows out by one smooth barrel profile;
|
||||||
|
// Catmull-Rom tangents (from neighbours) make every junction C1-smooth -> no kink, the
|
||||||
|
// corners follow the cushion and the cushion melts into the corners, bulged or not.
|
||||||
|
function facePath(b) {
|
||||||
|
const f = y => b * (1 - (y/HH) * (y/HH)); // 0 at top/bottom, max at the middle
|
||||||
|
const V = REST.map(([x, y]) => [x + Math.sign(x) * f(y), y]); // push the sides out, top/bottom stay
|
||||||
|
const n = V.length, I = [], O = [];
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const p0 = V[(k-1+n)%n], p1 = V[k], p2 = V[(k+1)%n];
|
||||||
|
let dx = p2[0]-p0[0], dy = p2[1]-p0[1]; // tangent direction (neighbours)
|
||||||
|
const dl = Math.hypot(dx, dy) || 1; dx /= dl; dy /= dl;
|
||||||
|
const ln = Math.hypot(p2[0]-p1[0], p2[1]-p1[1]) / 3; // handles ~ 1/3 of the adjacent chord
|
||||||
|
const lp = Math.hypot(p1[0]-p0[0], p1[1]-p0[1]) / 3; // -> chordal spline, no overshoot/facets
|
||||||
|
O.push([dx*ln, dy*ln]); I.push([-dx*lp, -dy*lp]);
|
||||||
|
}
|
||||||
|
return { i: I, o: O, v: V, c: true };
|
||||||
|
}
|
||||||
|
const facePathKeys = samples => ({ a: 1, k: samples.map((s, idx) => {
|
||||||
|
const kf = { t: s.t, s: [facePath(s.b)] };
|
||||||
|
if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; }
|
||||||
|
return kf;
|
||||||
|
}) });
|
||||||
|
|
||||||
|
// ---- animation tracks ------------------------------------------------------
|
||||||
|
// bottom-centre screen-y (the tile rests/squashes on its bottom edge)
|
||||||
|
const posY = anim([
|
||||||
|
{ t: 0, v: [cx, APEX, 0], e: EI }, // apex -> immediately falls (no dwell)
|
||||||
|
{ t: T_HIT, v: [cx, FLOOR, 0], e: ES }, // FAST fall (accelerate) -> floor
|
||||||
|
{ t: T_LIFT, v: [cx, FLOOR, 0], e: EO }, // sit on the floor while the cushion absorbs
|
||||||
|
{ t: OP, v: [cx, APEX, 0] }, // FAST rise (decelerate) -> apex = loop start
|
||||||
|
]);
|
||||||
|
// scale about the bottom anchor: stretch while falling, gentle/slow cushion squash on impact
|
||||||
|
const scl = anim([
|
||||||
|
{ t: 0, v: [100, 100, 100], e: ES },
|
||||||
|
{ t: T_HIT-5, v: [95, 106, 100], e: ES }, // stretch while falling fast
|
||||||
|
{ t: T_HIT, v: [104, 95, 100], e: ES }, // touch down
|
||||||
|
{ t: T_PEAK, v: [112, 86, 100], e: ES }, // soft squash peak (gentler, less plче)
|
||||||
|
{ t: T_REC, v: [100, 100, 100], e: ES }, // slow cushion release -> soft landing
|
||||||
|
{ t: OP, v: [100, 100, 100] },
|
||||||
|
]);
|
||||||
|
// a touch of skew through the squash (organic deform), back to 0
|
||||||
|
const skw = anim([
|
||||||
|
{ t: T_HIT, v: [0], e: ES }, { t: T_PEAK, v: [4], e: ES }, { t: T_REC, v: [0] }, { t: OP, v: [0] },
|
||||||
|
]);
|
||||||
|
// left/right cushion bulge: 0 -> gentle peak -> 0 (never inward)
|
||||||
|
const bulge = facePathKeys([
|
||||||
|
{ t: T_HIT, b: 0, e: ES }, { t: T_PEAK, b: 4, e: ES }, { t: T_REC, b: 0 }, { t: OP, b: 0 },
|
||||||
|
]);
|
||||||
|
// glint: face + glyph catch the light at the bounce
|
||||||
|
const faceCol = animColor([
|
||||||
|
{ t: T_HIT-2, v: FACE, e: ES }, { t: T_PEAK, v: FACE_GLINT, e: ES }, { t: T_REC, v: FACE }, { t: OP, v: FACE },
|
||||||
|
]);
|
||||||
|
const glyphCol = animColor([
|
||||||
|
{ t: T_HIT-2, v: BLACK, e: ES }, { t: T_PEAK, v: BLACK_LIT, e: ES }, { t: T_REC, v: BLACK }, { t: OP, v: BLACK },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---- layer (face + glyphs share the bounce/squash transform) ---------------
|
||||||
|
const faceShape = grp([ { ty: 'sh', d: 1, ks: bulge }, fill(faceCol), stroke(BORDER, 1.8) ], 'face');
|
||||||
|
const glyphE = glyph(G.E, 32, 0, -1, 1.0, glyphCol, 'E'); // centred Э
|
||||||
|
const glyph8 = glyph(G.D8, 8.5, HW-6.5, HH-7.5, 0.5, glyphCol, 'score'); // 8 in the corner
|
||||||
|
|
||||||
|
const tile = {
|
||||||
|
ddd: 0, ind: 1, ty: 4, nm: 'tile', sr: 1,
|
||||||
|
ks: { o: still(100), r: still(0), p: posY, a: still([0, HH, 0]), s: scl, sk: skw, sa: still(0) },
|
||||||
|
ao: 0, shapes: [ glyph8, glyphE, faceShape ], ip: 0, op: OP, st: 0, bm: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = {
|
||||||
|
v: '5.7.4', fr: FR, ip: 0, op: OP, w: W, h: H, nm: 'erudit-vk-loader', ddd: 0,
|
||||||
|
assets: [], layers: [ tile ], markers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = process.argv[2] || 'erudit.json';
|
||||||
|
const round = (k, v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : v);
|
||||||
|
fs.writeFileSync(out, JSON.stringify(root, round));
|
||||||
|
console.log('wrote', out, fs.statSync(out).size, 'bytes');
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
File diff suppressed because one or more lines are too long
+11
-7
@@ -43,8 +43,10 @@ per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
|||||||
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||||||
including obfuscated forms) and stored with the sender's IP. Each message carries an
|
including obfuscated forms) and stored with the sender's IP. Each message carries an
|
||||||
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
|
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
|
||||||
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
|
clears a reader's bit when they open the move history or chat, a wired `NudgeClearer`
|
||||||
clears a nudge when its recipient moves — both record the publish-to-read latency.
|
clears a nudge when its recipient moves, and a wired `NudgeExpirer` clears **all** of a game's
|
||||||
|
nudges when it finishes (any completion path) — the first two record the publish-to-read latency,
|
||||||
|
the completion expiry does not (it is not a read); chat messages stay unread on completion.
|
||||||
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
|
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
|
||||||
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
|
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
|
||||||
background reaper drops a robot friend request once its game has been finished for **7 days**.
|
background reaper drops a robot friend request once its game has been finished for **7 days**.
|
||||||
@@ -212,11 +214,13 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
|
|||||||
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
||||||
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
|
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
|
||||||
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
|
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
|
||||||
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
|
| `BACKEND_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). |
|
||||||
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
|
| `BACKEND_SMTP_PORT` | `587` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
| `BACKEND_SMTP_TLS` | — | Transport security: `ssl` (implicit TLS from connect) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); set it for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
||||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
| `BACKEND_SMTP_USERNAME` | — | SMTP AUTH user; empty relays without authentication. |
|
||||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
| `BACKEND_SMTP_PASSWORD` | — | SMTP AUTH password. |
|
||||||
|
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | From address. A deployed contour must use the prod domain (the relay only accepts its verified sender domain). |
|
||||||
|
| `BACKEND_PUBLIC_BASE_URL` | — | Canonical public origin (scheme + host) for links in the email. **Required when `BACKEND_SMTP_HOST` is set.** Never derived from a request Host header (anti-injection). |
|
||||||
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||||||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -173,15 +174,20 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
||||||
// so they are handed to the server (like the route groups) for the handlers.
|
// so they are handed to the server (like the route groups) for the handlers.
|
||||||
mailer := newMailer(cfg.SMTP, logger)
|
mailer := newMailer(cfg.SMTP, logger)
|
||||||
emails := account.NewEmailService(accounts, mailer)
|
emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL)
|
||||||
|
// Throttle confirm-code sends per recipient: at most one per minute and five per
|
||||||
|
// rolling hour, guarding against email bombing and the relay's own quota.
|
||||||
|
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
|
||||||
// Account linking & merge: the orchestrator over the account, merge and
|
// Account linking & merge: the orchestrator over the account, merge and
|
||||||
// session layers. Wired to the /api/v1/user/link REST surface below.
|
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||||
socialSvc.SetNotifier(hub)
|
socialSvc.SetNotifier(hub)
|
||||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
||||||
// A nudge the recipient answered by moving is marked read on the move path.
|
// A nudge the recipient answered by moving is marked read on the move path; every nudge in a
|
||||||
|
// game is marked read when the game finishes (a stale badge), on any completion path.
|
||||||
games.SetNudgeClearer(socialSvc.ClearNudges)
|
games.SetNudgeClearer(socialSvc.ClearNudges)
|
||||||
|
games.SetNudgeExpirer(socialSvc.ExpireNudges)
|
||||||
// Reap per-game disguised-robot friend requests once their game is long finished
|
// Reap per-game disguised-robot friend requests once their game is long finished
|
||||||
// (the robot ignores them; the row only pins the in-game "request sent" state).
|
// (the robot ignores them; the row only pins the in-game "request sent" state).
|
||||||
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
|
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
|
||||||
@@ -230,6 +236,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
// block and the banner admin console section.
|
// block and the banner admin console section.
|
||||||
adsSvc := ads.NewService(ads.NewStore(db))
|
adsSvc := ads.NewService(ads.NewStore(db))
|
||||||
|
|
||||||
|
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||||
|
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||||
|
var renderer *render.Client
|
||||||
|
if cfg.RendererURL != "" {
|
||||||
|
renderer = render.New(cfg.RendererURL)
|
||||||
|
}
|
||||||
|
|
||||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -251,6 +264,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
BanView: banView,
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Command dictgen dumps golden parity vectors from the committed dawg
|
||||||
|
// dictionaries so the TypeScript dawg reader can be checked byte-for-byte
|
||||||
|
// against the authoritative Go dafsa reader.
|
||||||
|
//
|
||||||
|
// For each *.dawg file it writes, into the output directory:
|
||||||
|
//
|
||||||
|
// - <name>.words.bin — every stored word as alphabet-index bytes, in index
|
||||||
|
// order, framed as [1-byte length][length index bytes]. The word at stream
|
||||||
|
// position k has IndexOfB == k.
|
||||||
|
// - <name>.neg.bin — negative lookups (sequences whose IndexOfB is -1), same
|
||||||
|
// framing, to exercise the not-found path at varying depths.
|
||||||
|
// - <name>.meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for
|
||||||
|
// a header-parse sanity cross-check on the TS side.
|
||||||
|
//
|
||||||
|
// It is a development tool (not built into any service), analogous to
|
||||||
|
// cmd/jetgen. Run it from the repository root:
|
||||||
|
//
|
||||||
|
// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out <dir>
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
dawg "github.com/iliadenisov/dafsa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// meta is the per-dictionary sanity payload cross-checked by the TS reader.
|
||||||
|
type meta struct {
|
||||||
|
NumAdded int `json:"numAdded"`
|
||||||
|
NumNodes int `json:"numNodes"`
|
||||||
|
NumEdges int `json:"numEdges"`
|
||||||
|
Alphabet int `json:"alphabet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
|
||||||
|
outDir := flag.String("out", "", "output directory for the golden files (required)")
|
||||||
|
negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *outDir == "" {
|
||||||
|
fail("-out is required")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||||
|
fail("mkdir out: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg"))
|
||||||
|
if err != nil {
|
||||||
|
fail("glob: %v", err)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
if len(files) == 0 {
|
||||||
|
fail("no .dawg files in %s", *dawgDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if err := process(f, *outDir, *negCount); err != nil {
|
||||||
|
fail("%s: %v", filepath.Base(f), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// process emits the golden files for a single dawg dictionary.
|
||||||
|
func process(path, outDir string, negCount int) error {
|
||||||
|
name := strings.TrimSuffix(filepath.Base(path), ".dawg")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
finder, err := dawg.Read(bytes.NewReader(data), 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read dawg: %w", err)
|
||||||
|
}
|
||||||
|
defer finder.Close()
|
||||||
|
|
||||||
|
// Stream every stored word in index order; keep a decimated sample and the
|
||||||
|
// maximum alphabet index for negative generation.
|
||||||
|
wf, err := os.Create(filepath.Join(outDir, name+".words.bin"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bw := bufio.NewWriter(wf)
|
||||||
|
|
||||||
|
var (
|
||||||
|
count int
|
||||||
|
maxIx byte
|
||||||
|
sample [][]byte
|
||||||
|
)
|
||||||
|
finder.EnumerateB(func(index int, word []byte, final bool) int {
|
||||||
|
if !final {
|
||||||
|
return 0 // Continue
|
||||||
|
}
|
||||||
|
if index != count {
|
||||||
|
panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count))
|
||||||
|
}
|
||||||
|
writeWord(bw, word)
|
||||||
|
for _, b := range word {
|
||||||
|
if b > maxIx {
|
||||||
|
maxIx = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count%4 == 0 && len(sample) < 60000 {
|
||||||
|
sample = append(sample, append([]byte(nil), word...))
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
return 0 // Continue
|
||||||
|
})
|
||||||
|
if err := bw.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := wf.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != finder.NumAdded() {
|
||||||
|
return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded())
|
||||||
|
}
|
||||||
|
|
||||||
|
alphabet := int(maxIx) + 1
|
||||||
|
|
||||||
|
// Negatives: mutate sampled real words and keep the ones the reader rejects.
|
||||||
|
nf, err := os.Create(filepath.Join(outDir, name+".neg.bin"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nbw := bufio.NewWriter(nf)
|
||||||
|
rng := rand.New(rand.NewSource(1))
|
||||||
|
neg := 0
|
||||||
|
for neg < negCount && len(sample) > 0 {
|
||||||
|
base := sample[rng.Intn(len(sample))]
|
||||||
|
cand := append([]byte(nil), base...)
|
||||||
|
switch rng.Intn(3) {
|
||||||
|
case 0: // extend by one index
|
||||||
|
cand = append(cand, byte(rng.Intn(alphabet)))
|
||||||
|
case 1: // flip one index
|
||||||
|
if len(cand) > 0 {
|
||||||
|
cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet))
|
||||||
|
}
|
||||||
|
case 2: // drop the tail and flip the new last index
|
||||||
|
if len(cand) > 1 {
|
||||||
|
cand = cand[:len(cand)-1]
|
||||||
|
cand[len(cand)-1] = byte(rng.Intn(alphabet))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if finder.IndexOfB(cand) == -1 {
|
||||||
|
writeWord(nbw, cand)
|
||||||
|
neg++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := nbw.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := nf.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet}
|
||||||
|
mb, err := json.MarshalIndent(m, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n",
|
||||||
|
name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeWord frames one index-byte word as [length][bytes].
|
||||||
|
func writeWord(w *bufio.Writer, word []byte) {
|
||||||
|
if len(word) > 255 {
|
||||||
|
panic(fmt.Sprintf("word too long to frame: %d", len(word)))
|
||||||
|
}
|
||||||
|
w.WriteByte(byte(len(word)))
|
||||||
|
w.Write(word)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
// Command validategen produces golden conformance fixtures for the TypeScript
|
||||||
|
// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays
|
||||||
|
// greedy games with the authoritative scrabble-solver engine to build realistic
|
||||||
|
// board positions, then records a battery of candidate plays — the engine's own
|
||||||
|
// top move, letter-mutated variants, random scatters and (on the empty board) an
|
||||||
|
// off-centre translation — each paired with the ground-truth result of
|
||||||
|
// ValidatePlayOpts (legal, score, the words formed). The TS conformance test
|
||||||
|
// replays these and must agree exactly.
|
||||||
|
//
|
||||||
|
// It is a development tool (not built into any service), analogous to
|
||||||
|
// cmd/dictgen. Run it from the repository root:
|
||||||
|
//
|
||||||
|
// go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out <dir>
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/selfplay"
|
||||||
|
dawg "github.com/iliadenisov/dafsa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blankTile marks a blank tile in a drawn hand (matches selfplay).
|
||||||
|
const blankTile byte = 0xff
|
||||||
|
|
||||||
|
// variantSpec pairs a variant label with its ruleset and dawg file.
|
||||||
|
type variantSpec struct {
|
||||||
|
name string
|
||||||
|
rules *rules.Ruleset
|
||||||
|
dawg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// cell is an occupied board square or a placement (alphabet-index letter).
|
||||||
|
type cell struct {
|
||||||
|
R, C, Letter int
|
||||||
|
Blank bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// word mirrors scrabble.Word in index space.
|
||||||
|
type word struct {
|
||||||
|
Row, Col, Dir int
|
||||||
|
Letters []int
|
||||||
|
Blanks []bool
|
||||||
|
Score int
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixture is one candidate play with the engine's ground-truth verdict.
|
||||||
|
type fixture struct {
|
||||||
|
Board int `json:"board"` // index into the boards list
|
||||||
|
Dir int `json:"dir"`
|
||||||
|
IgnoreCrossWords bool `json:"ignoreCrossWords"`
|
||||||
|
Tiles []cell `json:"tiles"`
|
||||||
|
Legal bool `json:"legal"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
Bonus int `json:"bonus"`
|
||||||
|
Main *word `json:"main,omitempty"`
|
||||||
|
Cross []word `json:"cross,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
|
||||||
|
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
|
||||||
|
// cross-test can drive the letter-space client path exactly as production does.
|
||||||
|
type alphaEntry struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantFile is the whole conformance payload for one variant.
|
||||||
|
type variantFile struct {
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Rows int `json:"rows"`
|
||||||
|
Cols int `json:"cols"`
|
||||||
|
Center int `json:"center"`
|
||||||
|
RackSize int `json:"rackSize"`
|
||||||
|
Bingo int `json:"bingo"`
|
||||||
|
Values []int `json:"values"`
|
||||||
|
Premiums []int `json:"premiums"` // row-major rules.Premium codes
|
||||||
|
Alphabet []alphaEntry `json:"alphabet"`
|
||||||
|
Boards [][]cell `json:"boards"`
|
||||||
|
Fixtures []fixture `json:"fixtures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
|
||||||
|
outDir := flag.String("out", "", "output directory for the fixture files (required)")
|
||||||
|
games := flag.Int("games", 6, "self-play games per (variant, rule)")
|
||||||
|
plies := flag.Int("plies", 40, "maximum plies captured per game")
|
||||||
|
flag.Parse()
|
||||||
|
if *outDir == "" {
|
||||||
|
fail("-out is required")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||||
|
fail("mkdir out: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
specs := []variantSpec{
|
||||||
|
{"scrabble_en", rules.English(), "en_sowpods.dawg"},
|
||||||
|
{"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"},
|
||||||
|
{"erudit_ru", rules.Erudit(), "ru_erudit.dawg"},
|
||||||
|
}
|
||||||
|
for _, sp := range specs {
|
||||||
|
if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil {
|
||||||
|
fail("%s: %v", sp.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
finder, err := dawg.Read(bytes.NewReader(data), 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read dawg: %w", err)
|
||||||
|
}
|
||||||
|
defer finder.Close()
|
||||||
|
|
||||||
|
rs := sp.rules
|
||||||
|
solver := scrabble.NewSolver(rs, finder)
|
||||||
|
|
||||||
|
out := variantFile{
|
||||||
|
Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center,
|
||||||
|
RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values,
|
||||||
|
Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture under both the standard rule and the single-word rule, building the
|
||||||
|
// board with the same rule so positions are reachable under it.
|
||||||
|
for _, ignore := range []bool{false, true} {
|
||||||
|
opts := scrabble.PlayOptions{IgnoreCrossWords: ignore}
|
||||||
|
for g := range games {
|
||||||
|
seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name)
|
||||||
|
playAndCapture(&out, rs, solver, opts, seed, plies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(&out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// playAndCapture greedily self-plays one game, recording candidate plays against
|
||||||
|
// each board position along the way.
|
||||||
|
func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) {
|
||||||
|
rng := rand.New(rand.NewSource(seed))
|
||||||
|
bag := selfplay.NewBag(rs, seed)
|
||||||
|
b := board.New(rs.Rows, rs.Cols)
|
||||||
|
hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)}
|
||||||
|
|
||||||
|
passes := 0
|
||||||
|
for turn := range plies {
|
||||||
|
p := turn % 2
|
||||||
|
rk := rackOf(hands[p], rs.Size())
|
||||||
|
moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts)
|
||||||
|
if len(moves) == 0 {
|
||||||
|
if passes++; passes >= 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
passes = 0
|
||||||
|
top := moves[0]
|
||||||
|
|
||||||
|
boardIdx := len(out.Boards)
|
||||||
|
out.Boards = append(out.Boards, boardCells(b))
|
||||||
|
captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng)
|
||||||
|
|
||||||
|
scrabble.Apply(b, top)
|
||||||
|
hands[p] = removeUsed(hands[p], top)
|
||||||
|
if need := rs.RackSize - len(hands[p]); need > 0 {
|
||||||
|
hands[p] = append(hands[p], bag.Draw(need)...)
|
||||||
|
}
|
||||||
|
if len(hands[p]) == 0 && bag.Len() == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureCandidates records the engine's top move plus derived candidates for one
|
||||||
|
// board, each with its ValidatePlayOpts verdict.
|
||||||
|
func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) {
|
||||||
|
size := rs.Size()
|
||||||
|
record := func(tiles []scrabble.Placement) {
|
||||||
|
if len(tiles) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine's own top move (legal).
|
||||||
|
record(top.Tiles)
|
||||||
|
|
||||||
|
// Letter-mutated variants: usually reject on the dictionary, occasionally form
|
||||||
|
// a different legal word.
|
||||||
|
for range 3 {
|
||||||
|
mut := clonePlacements(top.Tiles)
|
||||||
|
i := rng.Intn(len(mut))
|
||||||
|
mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size)
|
||||||
|
record(mut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random scatters: exercise geometry, dictionary and connectivity paths.
|
||||||
|
for range 3 {
|
||||||
|
record(randomScatter(b, size, 2+rng.Intn(4), rng))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single tiles abutting the board exercise the direction inference — a single
|
||||||
|
// tile is ambiguous, its orientation resolved from which axis it extends.
|
||||||
|
for range 3 {
|
||||||
|
if t, ok := randomAdjacentSingle(b, size, rng); ok {
|
||||||
|
record([]scrabble.Placement{t})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the empty board, an off-centre translation of the first move exercises the
|
||||||
|
// first-move centre rule.
|
||||||
|
if b.IsEmpty() {
|
||||||
|
shifted := clonePlacements(top.Tiles)
|
||||||
|
ok := true
|
||||||
|
for i := range shifted {
|
||||||
|
shifted[i].Row++
|
||||||
|
shifted[i].Col++
|
||||||
|
if !b.InBounds(shifted[i].Row, shifted[i].Col) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
record(shifted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeFixture validates a candidate against board b and serializes it with its
|
||||||
|
// ground truth. Word breakdown is recorded only for legal plays (the TS test
|
||||||
|
// checks words only then); an illegal play records legal=false alone.
|
||||||
|
func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture {
|
||||||
|
// Infer the orientation exactly as the backend evaluate does (dir-less), so the
|
||||||
|
// fixture matches the real eval path and pins the client's ported inference.
|
||||||
|
dir := playDirectionMirror(solver, b, tiles, opts)
|
||||||
|
fx := fixture{
|
||||||
|
Board: boardIdx,
|
||||||
|
Dir: int(dir),
|
||||||
|
IgnoreCrossWords: opts.IgnoreCrossWords,
|
||||||
|
Tiles: placementCells(tiles),
|
||||||
|
}
|
||||||
|
m, err := solver.ValidatePlayOpts(b, dir, tiles, opts)
|
||||||
|
if err == nil {
|
||||||
|
fx.Legal = true
|
||||||
|
fx.Score = m.Score
|
||||||
|
fx.Bonus = m.Bonus
|
||||||
|
fx.Main = toWord(m.Main)
|
||||||
|
for _, cw := range m.Cross {
|
||||||
|
fx.Cross = append(fx.Cross, *toWord(cw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fx
|
||||||
|
}
|
||||||
|
|
||||||
|
func placementCells(ts []scrabble.Placement) []cell {
|
||||||
|
cs := make([]cell, len(ts))
|
||||||
|
for i, t := range ts {
|
||||||
|
cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank}
|
||||||
|
}
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
func toWord(w scrabble.Word) *word {
|
||||||
|
letters := make([]int, len(w.Letters))
|
||||||
|
for i, l := range w.Letters {
|
||||||
|
letters[i] = int(l)
|
||||||
|
}
|
||||||
|
return &word{
|
||||||
|
Row: w.Row, Col: w.Col, Dir: int(w.Dir),
|
||||||
|
Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alphabetOf(rs *rules.Ruleset) []alphaEntry {
|
||||||
|
n := rs.Alphabet.Size()
|
||||||
|
out := make([]alphaEntry, n)
|
||||||
|
for i := range n {
|
||||||
|
ch, _ := rs.Alphabet.Character(byte(i))
|
||||||
|
out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func premiumCodes(rs *rules.Ruleset) []int {
|
||||||
|
codes := make([]int, rs.Rows*rs.Cols)
|
||||||
|
for i := range codes {
|
||||||
|
codes[i] = int(rs.PremiumAt(i))
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
func boardCells(b *board.Board) []cell {
|
||||||
|
var cs []cell
|
||||||
|
for r := 0; r < b.Rows(); r++ {
|
||||||
|
for c := 0; c < b.Cols(); c++ {
|
||||||
|
if b.Filled(r, c) {
|
||||||
|
v := b.At(r, c)
|
||||||
|
cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePlacements(ts []scrabble.Placement) []scrabble.Placement {
|
||||||
|
return append([]scrabble.Placement(nil), ts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomScatter picks n distinct empty in-bounds squares with random letters.
|
||||||
|
func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement {
|
||||||
|
seen := map[[2]int]bool{}
|
||||||
|
var ts []scrabble.Placement
|
||||||
|
for tries := 0; tries < n*20 && len(ts) < n; tries++ {
|
||||||
|
r := rng.Intn(b.Rows())
|
||||||
|
c := rng.Intn(b.Cols())
|
||||||
|
if seen[[2]int{r, c}] || b.Filled(r, c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[[2]int{r, c}] = true
|
||||||
|
ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0})
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomAdjacentSingle picks a random empty in-bounds square abutting at least one
|
||||||
|
// filled square, with a random letter — a single-tile play whose orientation the
|
||||||
|
// inference must resolve. It returns ok=false on an empty board.
|
||||||
|
func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) {
|
||||||
|
var cands [][2]int
|
||||||
|
for r := 0; r < b.Rows(); r++ {
|
||||||
|
for c := 0; c < b.Cols(); c++ {
|
||||||
|
if b.Filled(r, c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) {
|
||||||
|
cands = append(cands, [2]int{r, c})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return scrabble.Placement{}, false
|
||||||
|
}
|
||||||
|
rc := cands[rng.Intn(len(cands))]
|
||||||
|
return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// playDirectionMirror mirrors engine (*Game).playDirection: the geometric
|
||||||
|
// resolution, except a single tile under the single-word rule tries both
|
||||||
|
// orientations through the solver and keeps the higher-scoring legal one (H wins
|
||||||
|
// ties). It reproduces the orientation the backend evaluate infers.
|
||||||
|
func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction {
|
||||||
|
geo := resolveDirectionMirror(b, placements)
|
||||||
|
if len(placements) != 1 || !opts.IgnoreCrossWords {
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
best, found, bestScore := geo, false, 0
|
||||||
|
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
|
||||||
|
m, err := solver.ValidatePlayOpts(b, dir, placements, opts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || m.Score > bestScore {
|
||||||
|
best, found, bestScore = dir, true, m.Score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDirectionMirror mirrors engine.resolveDirection.
|
||||||
|
func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
|
||||||
|
if len(placements) >= 2 {
|
||||||
|
row := placements[0].Row
|
||||||
|
for _, p := range placements[1:] {
|
||||||
|
if p.Row != row {
|
||||||
|
return scrabble.Vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrabble.Horizontal
|
||||||
|
}
|
||||||
|
if len(placements) == 1 {
|
||||||
|
p := placements[0]
|
||||||
|
h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal)
|
||||||
|
v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical)
|
||||||
|
if v >= 2 && v > h {
|
||||||
|
return scrabble.Vertical
|
||||||
|
}
|
||||||
|
if h >= 2 {
|
||||||
|
return scrabble.Horizontal
|
||||||
|
}
|
||||||
|
if v >= 2 {
|
||||||
|
return scrabble.Vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrabble.Horizontal
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLengthMirror mirrors engine.runLength.
|
||||||
|
func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int {
|
||||||
|
dr, dc := 0, 1
|
||||||
|
if dir == scrabble.Vertical {
|
||||||
|
dr, dc = 1, 0
|
||||||
|
}
|
||||||
|
n := 1
|
||||||
|
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// rackOf builds a generation rack from a hand of tiles (reimplemented from the
|
||||||
|
// unexported selfplay helper).
|
||||||
|
func rackOf(tiles []byte, size int) rack.Rack {
|
||||||
|
r := rack.New(size)
|
||||||
|
for _, t := range tiles {
|
||||||
|
if t == blankTile {
|
||||||
|
r.AddBlank()
|
||||||
|
} else {
|
||||||
|
r.Add(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUsed returns the hand with the tiles consumed by m removed.
|
||||||
|
func removeUsed(tiles []byte, m scrabble.Move) []byte {
|
||||||
|
out := append([]byte(nil), tiles...)
|
||||||
|
for _, p := range m.Tiles {
|
||||||
|
want := p.Letter
|
||||||
|
if p.Blank {
|
||||||
|
want = blankTile
|
||||||
|
}
|
||||||
|
for i, t := range out {
|
||||||
|
if t == want {
|
||||||
|
out = append(out[:i], out[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolseed(b bool) int64 {
|
||||||
|
if b {
|
||||||
|
return 500000
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func variantSeed(name string) int64 {
|
||||||
|
var s int64
|
||||||
|
for _, r := range name {
|
||||||
|
s = s*131 + int64(r)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
+2
-1
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/pressly/goose/v3 v3.27.1
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
github.com/testcontainers/testcontainers-go v0.42.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||||
|
github.com/wneessen/go-mail v0.7.3
|
||||||
go.opentelemetry.io/otel v1.43.0
|
go.opentelemetry.io/otel v1.43.0
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
||||||
@@ -109,7 +110,7 @@ require (
|
|||||||
golang.org/x/net v0.53.0 // indirect
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
google.golang.org/grpc v1.80.0
|
google.golang.org/grpc v1.80.0
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -279,6 +279,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
|
||||||
|
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
@@ -400,6 +402,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import (
|
|||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
// Identity kinds recognised by the backend. Telegram and VK are platform identities,
|
||||||
// alongside platform identities; its confirmed flag is driven by the email
|
// auto-confirmed on first contact. Email is modelled as an identity alongside them; its
|
||||||
// confirm-code flow. Robot is a synthetic kind: each pooled
|
// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
|
||||||
// robot opponent is a durable account bound to one robot identity.
|
// each pooled robot opponent is a durable account bound to one robot identity.
|
||||||
const (
|
const (
|
||||||
KindTelegram = "telegram"
|
KindTelegram = "telegram"
|
||||||
|
KindVK = "vk"
|
||||||
KindEmail = "email"
|
KindEmail = "email"
|
||||||
KindRobot = "robot"
|
KindRobot = "robot"
|
||||||
)
|
)
|
||||||
@@ -120,13 +121,30 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionEmail returns the account owning the email identity externalID, creating
|
// ProvisionEmail returns the account owning the email identity externalID, creating
|
||||||
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
|
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
|
||||||
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
|
// seeded into its time zone and language seeded from the client's UI language. Like
|
||||||
// and leaves an existing account untouched, so a returning user's saved zone is never
|
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
|
||||||
// overwritten. The email account is created here (the code-request step), not at the
|
// returning user's saved zone and language are never overwritten. The email account is
|
||||||
// later login, so this is where its zone is seeded.
|
// created here (the code-request step), not at the later login, so this is where its
|
||||||
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
|
// zone and language are seeded. It is created flagged is_guest with an unconfirmed
|
||||||
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
|
// email identity: an abandoned, never-confirmed login is then reaped like any guest,
|
||||||
|
// freeing the reserved address, and confirming the code clears the guest flag.
|
||||||
|
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) {
|
||||||
|
return s.provision(ctx, KindEmail, externalID, provisionSeed{
|
||||||
|
timeZone: seedZone(browserTZ),
|
||||||
|
preferredLanguage: supportedLanguage(language),
|
||||||
|
isGuest: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportedLanguage returns code normalised to a supported UI language ("en" or
|
||||||
|
// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It
|
||||||
|
// accepts region-tagged codes ("ru-RU").
|
||||||
|
func supportedLanguage(code string) string {
|
||||||
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(code)), "-"); lang == "en" || lang == "ru" {
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||||
@@ -185,6 +203,28 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
|
|||||||
return acc, created, err
|
return acc, created, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting
|
||||||
|
// whether this call created it (first contact). On first contact only, it seeds the new
|
||||||
|
// account's preferred language from the VK languageCode (vk_language, when it maps to a
|
||||||
|
// supported language) and its display name sanitized from displayName — the name read
|
||||||
|
// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params —
|
||||||
|
// falling back to a generated placeholder when it yields no letters; an already-existing
|
||||||
|
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||||
|
func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) {
|
||||||
|
// Pre-check whether the identity already exists so the caller can act on first
|
||||||
|
// contact (mirrors ProvisionTelegram); a create race only mis-reports created for
|
||||||
|
// that one call.
|
||||||
|
_, err := s.findByIdentity(ctx, KindVK, externalID)
|
||||||
|
created := errors.Is(err, ErrNotFound)
|
||||||
|
if err != nil && !created {
|
||||||
|
return Account{}, false, err
|
||||||
|
}
|
||||||
|
seed := vkSeed(languageCode, displayName)
|
||||||
|
seed.timeZone = seedZone(browserTZ)
|
||||||
|
acc, err := s.provision(ctx, KindVK, externalID, seed)
|
||||||
|
return acc, created, err
|
||||||
|
}
|
||||||
|
|
||||||
// provision finds the account for (kind, externalID) or creates it with seed,
|
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||||
// collapsing a concurrent-create race on the identity unique constraint into a
|
// collapsing a concurrent-create race on the identity unique constraint into a
|
||||||
// re-read of the winner's account.
|
// re-read of the winner's account.
|
||||||
@@ -216,6 +256,11 @@ type provisionSeed struct {
|
|||||||
preferredLanguage string
|
preferredLanguage string
|
||||||
displayName string
|
displayName string
|
||||||
timeZone string
|
timeZone string
|
||||||
|
// isGuest creates the account flagged is_guest. It is set for an email-login
|
||||||
|
// account, which stays a guest until the address is confirmed (so an abandoned,
|
||||||
|
// never-confirmed login is reaped and its address freed); confirming clears the
|
||||||
|
// flag. Platform identities (telegram/vk) are durable from creation.
|
||||||
|
isGuest bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedZone returns browserTZ when it is a well-formed zone to persist at account
|
// seedZone returns browserTZ when it is a well-formed zone to persist at account
|
||||||
@@ -258,6 +303,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
|||||||
return seed
|
return seed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vkSeed derives the create-time seed from VK launch fields: a supported preferred
|
||||||
|
// language from languageCode (vk_language, normally a 2-letter code) and a display name
|
||||||
|
// from displayName (sanitized to the editable format), falling back to a generated
|
||||||
|
// placeholder in the seeded language when the name yields no usable letters. Unlike
|
||||||
|
// telegramSeed there is no @username fallback — VK provides only the name.
|
||||||
|
func vkSeed(languageCode, displayName string) provisionSeed {
|
||||||
|
var seed provisionSeed
|
||||||
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||||
|
seed.preferredLanguage = lang
|
||||||
|
}
|
||||||
|
name := sanitizeDisplayName(displayName)
|
||||||
|
if name == "" {
|
||||||
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
|
}
|
||||||
|
seed.displayName = name
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
|
||||||
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
|
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
|
||||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||||
@@ -406,8 +469,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
|||||||
tz = "UTC"
|
tz = "UTC"
|
||||||
}
|
}
|
||||||
insertAccount := table.Accounts.
|
insertAccount := table.Accounts.
|
||||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
|
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest).
|
||||||
VALUES(accountID, seed.displayName, lang, tz).
|
VALUES(accountID, seed.displayName, lang, tz, seed.isGuest).
|
||||||
RETURNING(table.Accounts.AllColumns)
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
var row model.Accounts
|
var row model.Accounts
|
||||||
@@ -421,7 +484,7 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
|||||||
table.Identities.Kind,
|
table.Identities.Kind,
|
||||||
table.Identities.ExternalID,
|
table.Identities.ExternalID,
|
||||||
table.Identities.Confirmed,
|
table.Identities.Confirmed,
|
||||||
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram)
|
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK)
|
||||||
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
|
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ const (
|
|||||||
emailCodeMaxAttempts = 5
|
emailCodeMaxAttempts = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Confirmation purposes recorded on a pending confirm-code row. They select what
|
||||||
|
// verifying the code or the deeplink token does: sign in (login) or link/confirm the
|
||||||
|
// address on the current account (link). Email change and account deletion add
|
||||||
|
// further purposes in later stages.
|
||||||
|
const (
|
||||||
|
purposeLogin = "login"
|
||||||
|
purposeLink = "link"
|
||||||
|
)
|
||||||
|
|
||||||
// Errors returned by the email confirm-code flow.
|
// Errors returned by the email confirm-code flow.
|
||||||
var (
|
var (
|
||||||
// ErrInvalidEmail is returned for an unparseable email address.
|
// ErrInvalidEmail is returned for an unparseable email address.
|
||||||
@@ -46,6 +55,9 @@ var (
|
|||||||
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
|
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
|
||||||
// ErrCodeMismatch is returned when the submitted code does not match.
|
// ErrCodeMismatch is returned when the submitted code does not match.
|
||||||
ErrCodeMismatch = errors.New("account: confirmation code does not match")
|
ErrCodeMismatch = errors.New("account: confirmation code does not match")
|
||||||
|
// ErrTooManyRequests is returned when confirm-code sends to an address are being
|
||||||
|
// requested too frequently (the resend cooldown or the rolling-hour cap).
|
||||||
|
ErrTooManyRequests = errors.New("account: too many code requests")
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
|
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
|
||||||
@@ -55,14 +67,58 @@ var (
|
|||||||
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
||||||
// and using an email as a login reuses this mechanism.
|
// and using an email as a login reuses this mechanism.
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
store *Store
|
store *Store
|
||||||
mailer Mailer
|
mailer Mailer
|
||||||
now func() time.Time
|
baseURL string
|
||||||
|
limiter *SendLimiter
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEmailService constructs an EmailService over store, sending via mailer.
|
// NewEmailService constructs an EmailService over store, sending via mailer. baseURL
|
||||||
func NewEmailService(store *Store, mailer Mailer) *EmailService {
|
// is the canonical public origin (scheme + host) used to build the one-tap confirm
|
||||||
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }}
|
// deeplink and the email footer landing link; an empty baseURL omits the deeplink
|
||||||
|
// (development / log mailer).
|
||||||
|
func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService {
|
||||||
|
return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are
|
||||||
|
// not throttled — production wires a limiter; tests leave it off.
|
||||||
|
func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l }
|
||||||
|
|
||||||
|
// allowSend reports whether a confirm-code send to email is permitted now, recording
|
||||||
|
// it when so. A nil limiter permits every send.
|
||||||
|
func (s *EmailService) allowSend(email string) bool {
|
||||||
|
return s.limiter == nil || s.limiter.Allow(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueCode generates a fresh confirm-code for (accountID, email), replaces any prior
|
||||||
|
// pending confirmation, and mails the branded code in locale; purpose selects the
|
||||||
|
// email wording. Only the SHA-256 hash of the code is stored.
|
||||||
|
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
|
||||||
|
code, codeHash, err := generateCode()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, s.now().Add(emailCodeTTL)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg, err := renderConfirmationEmail(purpose, code, "", s.baseURL, locale)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg.To = email
|
||||||
|
return s.mailer.Send(ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// accountLocale returns the account's preferred UI language for localising email,
|
||||||
|
// defaulting to "en" when the account cannot be loaded.
|
||||||
|
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
|
||||||
|
acc, err := s.store.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
return acc.PreferredLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
|
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
|
||||||
@@ -73,6 +129,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !s.allowSend(addr) {
|
||||||
|
return ErrTooManyRequests
|
||||||
|
}
|
||||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -83,16 +142,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
}
|
}
|
||||||
return ErrEmailTaken
|
return ErrEmailTaken
|
||||||
}
|
}
|
||||||
code, hash, err := generateCode()
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
subject := "Your Scrabble confirmation code"
|
|
||||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
|
||||||
return s.mailer.Send(ctx, addr, subject, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
||||||
@@ -127,32 +177,26 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
||||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
|
||||||
// the unauthenticated email-login entry point and, unlike RequestCode,
|
// durable once the code is confirmed. It is the unauthenticated email-login entry
|
||||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
// point and, unlike RequestCode, does not refuse an already-confirmed email — that is
|
||||||
// login. The code is mailed to the address, so only its real owner can complete
|
// the ordinary returning-user login. The code is mailed to the address, so only its
|
||||||
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
|
// real owner can complete the login. On first contact browserTZ (the client's
|
||||||
// seeds the new account's time zone. It returns the target account id for the
|
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
|
||||||
// subsequent LoginWithCode.
|
// language. It returns the target account id for the subsequent LoginWithCode.
|
||||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
|
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
|
||||||
addr, err := normalizeEmail(email)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
|
if !s.allowSend(addr) {
|
||||||
|
return uuid.UUID{}, ErrTooManyRequests
|
||||||
|
}
|
||||||
|
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
code, hash, err := generateCode()
|
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
|
||||||
if err != nil {
|
|
||||||
return uuid.UUID{}, err
|
|
||||||
}
|
|
||||||
if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
|
||||||
return uuid.UUID{}, err
|
|
||||||
}
|
|
||||||
subject := "Your Scrabble login code"
|
|
||||||
body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
|
||||||
if err := s.mailer.Send(ctx, addr, subject, body); err != nil {
|
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
return acc.ID, nil
|
return acc.ID, nil
|
||||||
@@ -191,6 +235,9 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
|
|||||||
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
|
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
|
||||||
return Account{}, err
|
return Account{}, err
|
||||||
}
|
}
|
||||||
|
if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
return s.store.GetByID(ctx, acc.ID)
|
return s.store.GetByID(ctx, acc.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
tmpltext "text/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// emailBrandColor is the single accent used in the confirmation email — a calm
|
||||||
|
// tile green, matching the "no riot of colours" brief.
|
||||||
|
const emailBrandColor = "#2f7d4f"
|
||||||
|
|
||||||
|
// confirmEmailView is the fully-localised data the confirmation email templates
|
||||||
|
// render. Every string is resolved before rendering, so the templates carry no
|
||||||
|
// localisation logic.
|
||||||
|
type confirmEmailView struct {
|
||||||
|
Brand string
|
||||||
|
Heading string
|
||||||
|
Intro string
|
||||||
|
Code string
|
||||||
|
Expiry string
|
||||||
|
CTALabel string
|
||||||
|
DeeplinkURL string
|
||||||
|
FooterIgnore string
|
||||||
|
LandingURL string
|
||||||
|
LandingLabel string
|
||||||
|
Preheader string
|
||||||
|
Locale string
|
||||||
|
Accent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailCopy is the purpose- and locale-specific wording of a confirmation email.
|
||||||
|
type emailCopy struct {
|
||||||
|
Subject string
|
||||||
|
Preheader string
|
||||||
|
Heading string
|
||||||
|
Intro string
|
||||||
|
CTALabel string
|
||||||
|
FooterIgnore string
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmEmailCopy holds the wording per (purpose, locale). Unknown purposes fall
|
||||||
|
// back to the neutral link wording and unknown locales fall back to English.
|
||||||
|
var confirmEmailCopy = map[string]map[string]emailCopy{
|
||||||
|
purposeLogin: {
|
||||||
|
"en": {
|
||||||
|
Subject: "Your Erudit sign-in code",
|
||||||
|
Preheader: "Your sign-in code",
|
||||||
|
Heading: "Sign in to Erudit",
|
||||||
|
Intro: "Enter this code to sign in:",
|
||||||
|
CTALabel: "Sign in with one tap",
|
||||||
|
FooterIgnore: "If you didn't request this email, you can safely ignore it.",
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
Subject: "Код для входа в Эрудит",
|
||||||
|
Preheader: "Ваш код для входа",
|
||||||
|
Heading: "Вход в Эрудит",
|
||||||
|
Intro: "Введите этот код, чтобы войти в игру:",
|
||||||
|
CTALabel: "Войти одним нажатием",
|
||||||
|
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
purposeLink: {
|
||||||
|
"en": {
|
||||||
|
Subject: "Your Erudit confirmation code",
|
||||||
|
Preheader: "Your confirmation code",
|
||||||
|
Heading: "Confirm your e-mail",
|
||||||
|
Intro: "Enter this code to confirm your address:",
|
||||||
|
CTALabel: "Confirm with one tap",
|
||||||
|
FooterIgnore: "If you didn't request this email, you can safely ignore it.",
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
Subject: "Код подтверждения Эрудит",
|
||||||
|
Preheader: "Ваш код подтверждения",
|
||||||
|
Heading: "Подтверждение e-mail",
|
||||||
|
Intro: "Введите этот код, чтобы подтвердить адрес:",
|
||||||
|
CTALabel: "Подтвердить одним нажатием",
|
||||||
|
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailBrand is the brand wordmark per locale.
|
||||||
|
var emailBrand = map[string]string{"en": "Erudit", "ru": "Эрудит"}
|
||||||
|
|
||||||
|
// emailExpiry formats the code-lifetime line per locale (abbreviated minutes to
|
||||||
|
// avoid plural agreement).
|
||||||
|
func emailExpiry(locale string, d time.Duration) string {
|
||||||
|
min := int(d / time.Minute)
|
||||||
|
if locale == "ru" {
|
||||||
|
return fmt.Sprintf("Код действует %d мин.", min)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("The code is valid for %d minutes.", min)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeLocale maps an account language to a supported email locale, defaulting
|
||||||
|
// to English.
|
||||||
|
func normalizeLocale(locale string) string {
|
||||||
|
if locale == "ru" {
|
||||||
|
return "ru"
|
||||||
|
}
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderConfirmationEmail builds the branded confirmation email for purpose in
|
||||||
|
// locale: a large readable code plus a one-tap deeplink button, with an
|
||||||
|
// ignore-notice footer and a landing link. Both a plain-text body and an HTML
|
||||||
|
// alternative are produced. deeplinkURL is the absolute /confirm link and
|
||||||
|
// landingURL the public landing origin.
|
||||||
|
func renderConfirmationEmail(purpose, code, deeplinkURL, landingURL, locale string) (Message, error) {
|
||||||
|
loc := normalizeLocale(locale)
|
||||||
|
byLocale, ok := confirmEmailCopy[purpose]
|
||||||
|
if !ok {
|
||||||
|
byLocale = confirmEmailCopy[purposeLink]
|
||||||
|
}
|
||||||
|
cp := byLocale[loc]
|
||||||
|
view := confirmEmailView{
|
||||||
|
Brand: emailBrand[loc],
|
||||||
|
Heading: cp.Heading,
|
||||||
|
Intro: cp.Intro,
|
||||||
|
Code: code,
|
||||||
|
Expiry: emailExpiry(loc, emailCodeTTL),
|
||||||
|
CTALabel: cp.CTALabel,
|
||||||
|
DeeplinkURL: deeplinkURL,
|
||||||
|
FooterIgnore: cp.FooterIgnore,
|
||||||
|
LandingURL: landingURL,
|
||||||
|
LandingLabel: emailBrand[loc],
|
||||||
|
Preheader: cp.Preheader,
|
||||||
|
Locale: loc,
|
||||||
|
Accent: emailBrandColor,
|
||||||
|
}
|
||||||
|
var html strings.Builder
|
||||||
|
if err := confirmEmailHTML.Execute(&html, view); err != nil {
|
||||||
|
return Message{}, fmt.Errorf("account: render confirmation email (html): %w", err)
|
||||||
|
}
|
||||||
|
var text strings.Builder
|
||||||
|
if err := confirmEmailText.Execute(&text, view); err != nil {
|
||||||
|
return Message{}, fmt.Errorf("account: render confirmation email (text): %w", err)
|
||||||
|
}
|
||||||
|
return Message{Subject: cp.Subject, Text: text.String(), HTML: html.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmEmailHTML is a compact, image-free, mobile-friendly HTML email. Layout is
|
||||||
|
// table-based for broad mail-client compatibility and all styling is inlined
|
||||||
|
// because clients strip <style> blocks.
|
||||||
|
var confirmEmailHTML = template.Must(template.New("confirmEmailHTML").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="{{.Locale}}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Brand}}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f5f7;">
|
||||||
|
<span style="display:none;max-height:0;overflow:hidden;opacity:0;">{{.Preheader}}</span>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:24px 12px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:460px;background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||||
|
<tr><td style="padding:28px 32px 4px;">
|
||||||
|
<div style="font-size:15px;font-weight:700;letter-spacing:.04em;color:{{.Accent}};">{{.Brand}}</div>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:8px 32px 0;">
|
||||||
|
<h1 style="margin:0;font-size:20px;line-height:1.3;color:#111827;font-weight:600;">{{.Heading}}</h1>
|
||||||
|
<p style="margin:12px 0 0;font-size:15px;line-height:1.5;color:#374151;">{{.Intro}}</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:18px 32px 0;">
|
||||||
|
<div style="font-size:34px;font-weight:700;letter-spacing:8px;text-align:center;color:#111827;background:#f3f4f6;border-radius:10px;padding:18px 0;font-family:'SFMono-Regular',Consolas,Menlo,monospace;">{{.Code}}</div>
|
||||||
|
<p style="margin:10px 0 0;font-size:13px;line-height:1.5;color:#6b7280;text-align:center;">{{.Expiry}}</p>
|
||||||
|
</td></tr>
|
||||||
|
{{if .DeeplinkURL}}<tr><td style="padding:22px 32px 0;" align="center">
|
||||||
|
<a href="{{.DeeplinkURL}}" style="display:inline-block;background:{{.Accent}};color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:12px 26px;border-radius:9px;">{{.CTALabel}}</a>
|
||||||
|
</td></tr>
|
||||||
|
{{end}}<tr><td style="padding:26px 32px 28px;">
|
||||||
|
<hr style="border:none;border-top:1px solid #eceef1;margin:0 0 16px;">
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.6;color:#9ca3af;">{{.FooterIgnore}}</p>
|
||||||
|
<p style="margin:10px 0 0;font-size:12px;color:#9ca3af;"><a href="{{.LandingURL}}" style="color:#6b7280;text-decoration:none;">{{.LandingLabel}}</a></p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`))
|
||||||
|
|
||||||
|
// confirmEmailText is the plain-text alternative (and multipart fallback).
|
||||||
|
var confirmEmailText = tmpltext.Must(tmpltext.New("confirmEmailText").Parse(`{{.Brand}}
|
||||||
|
|
||||||
|
{{.Heading}}
|
||||||
|
|
||||||
|
{{.Intro}}
|
||||||
|
|
||||||
|
{{.Code}}
|
||||||
|
|
||||||
|
{{.Expiry}}
|
||||||
|
|
||||||
|
{{if .DeeplinkURL}}{{.CTALabel}}:
|
||||||
|
{{.DeeplinkURL}}
|
||||||
|
|
||||||
|
{{end}}—
|
||||||
|
{{.FooterIgnore}}
|
||||||
|
{{.LandingLabel}} — {{.LandingURL}}
|
||||||
|
`))
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRenderConfirmationEmail checks that each (purpose, locale) renders a localised
|
||||||
|
// subject, embeds the code and the one-tap deeplink in both bodies, and produces HTML.
|
||||||
|
func TestRenderConfirmationEmail(t *testing.T) {
|
||||||
|
const deeplink = "https://erudit-game.ru/app/#/confirm/tok123"
|
||||||
|
cases := []struct {
|
||||||
|
name, purpose, locale, subjectSub string
|
||||||
|
}{
|
||||||
|
{"login ru", purposeLogin, "ru", "вход"},
|
||||||
|
{"login en", purposeLogin, "en", "sign-in"},
|
||||||
|
{"link ru", purposeLink, "ru", "подтвержд"},
|
||||||
|
{"link en", purposeLink, "en", "confirmation"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
msg, err := renderConfirmationEmail(c.purpose, "123456", deeplink, "https://erudit-game.ru", c.locale)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(msg.Subject), c.subjectSub) {
|
||||||
|
t.Errorf("subject %q does not contain %q", msg.Subject, c.subjectSub)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Text, "123456") || !strings.Contains(msg.HTML, "123456") {
|
||||||
|
t.Error("code missing from a body")
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Text, deeplink) || !strings.Contains(msg.HTML, "confirm/tok123") {
|
||||||
|
t.Error("deeplink missing from a body")
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.HTML, "<html") {
|
||||||
|
t.Error("HTML body is not HTML")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderConfirmationEmailUnknownLocaleDefaultsEnglish falls back to English for an
|
||||||
|
// unsupported locale rather than erroring or emitting an empty subject.
|
||||||
|
func TestRenderConfirmationEmailUnknownLocaleDefaultsEnglish(t *testing.T) {
|
||||||
|
msg, err := renderConfirmationEmail(purposeLogin, "000000", "", "https://erudit-game.ru", "de")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(msg.Subject), "sign-in") {
|
||||||
|
t.Errorf("unknown locale should default to English, got subject %q", msg.Subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,16 +26,10 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
code, hash, err := generateCode()
|
if !s.allowSend(addr) {
|
||||||
if err != nil {
|
return ErrTooManyRequests
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
||||||
return err
|
|
||||||
}
|
|
||||||
subject := "Your Scrabble confirmation code"
|
|
||||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
|
||||||
return s.mailer.Send(ctx, addr, subject, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||||
|
|||||||
@@ -3,33 +3,78 @@ package account
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"strconv"
|
||||||
"net/smtp"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wneessen/go-mail"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Message is a transactional email to send through a Mailer. Text is the
|
||||||
|
// required plain-text body and doubles as the multipart/alternative fallback;
|
||||||
|
// HTML, when non-empty, is the preferred body a capable client renders instead.
|
||||||
|
type Message struct {
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
Text string
|
||||||
|
HTML string
|
||||||
|
}
|
||||||
|
|
||||||
// Mailer delivers a transactional email. It is the seam behind which the email
|
// Mailer delivers a transactional email. It is the seam behind which the email
|
||||||
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
|
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
|
||||||
// fixture (see docs/TESTING.md: no real network in tests). The context is offered
|
// fixture (see docs/TESTING.md: no real network in tests). The context bounds the
|
||||||
// for cancellation; the standard-library SMTP implementation sends synchronously
|
// delivery and is honoured by the SMTP implementation.
|
||||||
// and ignores it.
|
|
||||||
type Mailer interface {
|
type Mailer interface {
|
||||||
Send(ctx context.Context, to, subject, body string) error
|
Send(ctx context.Context, msg Message) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
||||||
// instead, so a deployment without a relay still runs (the code lands in the log).
|
// instead, so a deployment without a relay still runs (the code lands in the log).
|
||||||
|
// TLS is always used and no client certificate is required — only the server
|
||||||
|
// certificate is validated against the system roots.
|
||||||
type SMTPConfig struct {
|
type SMTPConfig struct {
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
From string
|
From string
|
||||||
|
// TLS selects the transport security: "ssl" for implicit TLS from connect, or
|
||||||
|
// "starttls" to upgrade a plaintext connection. Empty derives the mode from the
|
||||||
|
// port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on
|
||||||
|
// a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS).
|
||||||
|
TLS string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
|
const (
|
||||||
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
|
// SMTP transport-security modes for SMTPConfig.TLS.
|
||||||
|
smtpTLSImplicit = "ssl"
|
||||||
|
smtpTLSSTARTTLS = "starttls"
|
||||||
|
// smtpDialTimeout bounds a single relay connect-and-send. The confirm-code send
|
||||||
|
// is synchronous on the request path, so an unreachable relay must fail fast
|
||||||
|
// rather than hold the request open.
|
||||||
|
smtpDialTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// tlsMode resolves the transport-security mode for the relay: the explicitly
|
||||||
|
// configured SMTPConfig.TLS, or — when unset — implicit TLS on the conventional SSL
|
||||||
|
// port 465 and STARTTLS on any other port.
|
||||||
|
func (cfg SMTPConfig) tlsMode(port int) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cfg.TLS)) {
|
||||||
|
case smtpTLSImplicit, "tls":
|
||||||
|
return smtpTLSImplicit
|
||||||
|
case smtpTLSSTARTTLS:
|
||||||
|
return smtpTLSSTARTTLS
|
||||||
|
}
|
||||||
|
if port == 465 {
|
||||||
|
return smtpTLSImplicit
|
||||||
|
}
|
||||||
|
return smtpTLSSTARTTLS
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTPMailer sends mail through an SMTP relay using go-mail. When a username is
|
||||||
|
// set it authenticates, auto-discovering the strongest mechanism the relay
|
||||||
|
// advertises; otherwise it relays unauthenticated.
|
||||||
type SMTPMailer struct {
|
type SMTPMailer struct {
|
||||||
cfg SMTPConfig
|
cfg SMTPConfig
|
||||||
}
|
}
|
||||||
@@ -39,29 +84,49 @@ func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
|
|||||||
return SMTPMailer{cfg: cfg}
|
return SMTPMailer{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send delivers a plain-text UTF-8 message to to via the configured relay.
|
// Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
|
||||||
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
|
// is set the message is multipart/alternative (plain text plus HTML); otherwise
|
||||||
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
|
// it is plain text only.
|
||||||
var auth smtp.Auth
|
func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
|
||||||
if m.cfg.Username != "" {
|
port, err := strconv.Atoi(m.cfg.Port)
|
||||||
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
if err != nil {
|
||||||
|
return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err)
|
||||||
}
|
}
|
||||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil {
|
opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)}
|
||||||
return fmt.Errorf("account: send mail to %s: %w", to, err)
|
if m.cfg.tlsMode(port) == smtpTLSImplicit {
|
||||||
|
opts = append(opts, mail.WithSSL())
|
||||||
|
} else {
|
||||||
|
opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory))
|
||||||
|
}
|
||||||
|
if m.cfg.Username != "" {
|
||||||
|
opts = append(opts,
|
||||||
|
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
|
||||||
|
mail.WithUsername(m.cfg.Username),
|
||||||
|
mail.WithPassword(m.cfg.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
client, err := mail.NewClient(m.cfg.Host, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("account: build mail client: %w", err)
|
||||||
|
}
|
||||||
|
out := mail.NewMsg()
|
||||||
|
if err := out.From(m.cfg.From); err != nil {
|
||||||
|
return fmt.Errorf("account: set From %q: %w", m.cfg.From, err)
|
||||||
|
}
|
||||||
|
if err := out.To(msg.To); err != nil {
|
||||||
|
return fmt.Errorf("account: set To %q: %w", msg.To, err)
|
||||||
|
}
|
||||||
|
out.Subject(msg.Subject)
|
||||||
|
out.SetBodyString(mail.TypeTextPlain, msg.Text)
|
||||||
|
if msg.HTML != "" {
|
||||||
|
out.AddAlternativeString(mail.TypeTextHTML, msg.HTML)
|
||||||
|
}
|
||||||
|
if err := client.DialAndSendWithContext(ctx, out); err != nil {
|
||||||
|
return fmt.Errorf("account: send mail to %s: %w", msg.To, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// message renders a minimal RFC 5322 plain-text email.
|
|
||||||
func message(from, to, subject, body string) []byte {
|
|
||||||
return []byte("From: " + from + "\r\n" +
|
|
||||||
"To: " + to + "\r\n" +
|
|
||||||
"Subject: " + subject + "\r\n" +
|
|
||||||
"MIME-Version: 1.0\r\n" +
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
|
||||||
"\r\n" + body + "\r\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogMailer logs the message instead of sending it. It is the default when no
|
// LogMailer logs the message instead of sending it. It is the default when no
|
||||||
// SMTP relay is configured and is intended for development only: it logs the body,
|
// SMTP relay is configured and is intended for development only: it logs the body,
|
||||||
// which carries the confirm-code, so it must not be used in production.
|
// which carries the confirm-code, so it must not be used in production.
|
||||||
@@ -74,11 +139,12 @@ func NewLogMailer(log *zap.Logger) LogMailer {
|
|||||||
return LogMailer{log: log}
|
return LogMailer{log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send logs the message at info level and reports success.
|
// Send logs the message at info level and reports success. It logs the plain-text
|
||||||
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
|
// body only (which carries the confirm-code); the HTML alternative is omitted.
|
||||||
|
func (m LogMailer) Send(_ context.Context, msg Message) error {
|
||||||
if m.log != nil {
|
if m.log != nil {
|
||||||
m.log.Info("email not sent (log mailer)",
|
m.log.Info("email not sent (log mailer)",
|
||||||
zap.String("to", to), zap.String("subject", subject), zap.String("body", body))
|
zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestSMTPTLSMode covers the explicit TLS mode and the port-based fallback, including
|
||||||
|
// the non-standard Selectel ports (1127 = SSL, 1126 = STARTTLS) that the 465 heuristic
|
||||||
|
// alone cannot classify.
|
||||||
|
func TestSMTPTLSMode(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tls string
|
||||||
|
port int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"explicit ssl on a custom port (Selectel 1127)", "ssl", 1127, smtpTLSImplicit},
|
||||||
|
{"explicit starttls on a custom port (Selectel 1126)", "starttls", 1126, smtpTLSSTARTTLS},
|
||||||
|
{"tls is an alias for ssl", "TLS", 2525, smtpTLSImplicit},
|
||||||
|
{"empty derives implicit TLS on 465", "", 465, smtpTLSImplicit},
|
||||||
|
{"empty derives STARTTLS on 587", "", 587, smtpTLSSTARTTLS},
|
||||||
|
{"an unknown value falls back to the port heuristic", "bogus", 465, smtpTLSImplicit},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := (SMTPConfig{TLS: c.tls}).tlsMode(c.port); got != c.want {
|
||||||
|
t.Errorf("tlsMode(TLS=%q, port=%d) = %q, want %q", c.tls, c.port, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
|||||||
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVKSeed covers the pure mapping from VK launch fields to the create-time account
|
||||||
|
// seed: supported-language detection from vk_language (bare and region-tagged) and the
|
||||||
|
// display name sanitized from the client-supplied name. Unlike Telegram there is no
|
||||||
|
// @username fallback — VK provides only the name.
|
||||||
|
func TestVKSeed(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
languageCode, displayName string
|
||||||
|
wantLang, wantName string
|
||||||
|
}{
|
||||||
|
"ru bare": {"ru", "Иван", "ru", "Иван"},
|
||||||
|
"en region-tagged": {"en-US", "John", "en", "John"},
|
||||||
|
"full name kept": {"ru", "Иван Петров", "ru", "Иван Петров"},
|
||||||
|
"unknown language": {"uk", "Тарас", "", "Тарас"},
|
||||||
|
"empty language": {"", "Neo", "", "Neo"},
|
||||||
|
"trimmed": {" RU ", " Anna ", "ru", "Anna"},
|
||||||
|
"emoji stripped": {"en", "🎮Kaya🎮", "en", "Kaya"},
|
||||||
|
}
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
got := vkSeed(tc.languageCode, tc.displayName)
|
||||||
|
if got.preferredLanguage != tc.wantLang {
|
||||||
|
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
|
||||||
|
}
|
||||||
|
if got.displayName != tc.wantName {
|
||||||
|
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVKSeedPlaceholder checks a VK name with no usable letters falls back to a
|
||||||
|
// generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
|
||||||
|
func TestVKSeedPlaceholder(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
languageCode, displayName string
|
||||||
|
wantRe string
|
||||||
|
}{
|
||||||
|
"en empty": {"en", "", `^Player-\d{5}$`},
|
||||||
|
"ru empty": {"ru", "", `^Игрок-\d{5}$`},
|
||||||
|
"default en": {"uk", "", `^Player-\d{5}$`},
|
||||||
|
"name garbage": {"ru", "123!@#", `^Игрок-\d{5}$`},
|
||||||
|
}
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
got := vkSeed(tc.languageCode, tc.displayName).displayName
|
||||||
|
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
|
||||||
|
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendLimiter throttles confirm-code sends per recipient address: it enforces a
|
||||||
|
// minimum cooldown between two sends and a cap over a rolling hour. It guards against
|
||||||
|
// email bombing and protects the relay's own quota. State is in-memory (per process,
|
||||||
|
// reset on restart) and keyed by the normalised recipient address, which is adequate
|
||||||
|
// for the single-instance backend. Safe for concurrent use.
|
||||||
|
type SendLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cooldown time.Duration
|
||||||
|
perHour int
|
||||||
|
now func() time.Time
|
||||||
|
sends map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSendLimiter returns a SendLimiter allowing at most one send per cooldown and at
|
||||||
|
// most perHour sends over any rolling hour, to the same recipient.
|
||||||
|
func NewSendLimiter(cooldown time.Duration, perHour int) *SendLimiter {
|
||||||
|
return &SendLimiter{
|
||||||
|
cooldown: cooldown,
|
||||||
|
perHour: perHour,
|
||||||
|
now: func() time.Time { return time.Now() },
|
||||||
|
sends: make(map[string][]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether a send to key is permitted now, recording the send when it is.
|
||||||
|
// It is denied when the last send was within the cooldown or the rolling-hour cap is
|
||||||
|
// already reached.
|
||||||
|
func (l *SendLimiter) Allow(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := l.now()
|
||||||
|
cutoff := now.Add(-time.Hour)
|
||||||
|
kept := l.sends[key][:0]
|
||||||
|
for _, t := range l.sends[key] {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
kept = append(kept, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := len(kept); n > 0 && now.Sub(kept[n-1]) < l.cooldown {
|
||||||
|
l.set(key, kept)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(kept) >= l.perHour {
|
||||||
|
l.set(key, kept)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l.set(key, append(kept, now))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// set stores the retained send times for key, dropping the entry entirely once empty
|
||||||
|
// so the map stays bounded to recipients active within the last hour.
|
||||||
|
func (l *SendLimiter) set(key string, times []time.Time) {
|
||||||
|
if len(times) == 0 {
|
||||||
|
delete(l.sends, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.sends[key] = times
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSendLimiter checks the per-recipient cooldown and the rolling-hour cap, and
|
||||||
|
// that recipients are throttled independently.
|
||||||
|
func TestSendLimiter(t *testing.T) {
|
||||||
|
base := time.Now()
|
||||||
|
now := base
|
||||||
|
l := NewSendLimiter(time.Minute, 3)
|
||||||
|
l.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("send 1 should be allowed")
|
||||||
|
}
|
||||||
|
if l.Allow("a") {
|
||||||
|
t.Fatal("immediate resend must be blocked by the cooldown")
|
||||||
|
}
|
||||||
|
if !l.Allow("b") {
|
||||||
|
t.Fatal("a different recipient is independent")
|
||||||
|
}
|
||||||
|
|
||||||
|
now = base.Add(time.Minute)
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("send 2 after the cooldown should be allowed")
|
||||||
|
}
|
||||||
|
now = base.Add(2 * time.Minute)
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("send 3 should be allowed")
|
||||||
|
}
|
||||||
|
now = base.Add(3 * time.Minute)
|
||||||
|
if l.Allow("a") {
|
||||||
|
t.Fatal("send 4 within the hour must be blocked by the cap")
|
||||||
|
}
|
||||||
|
|
||||||
|
now = base.Add(time.Hour + time.Minute)
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("after the rolling hour the cap resets")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||||
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||||
|
|||||||
@@ -142,6 +142,11 @@
|
|||||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .VKID}}
|
||||||
|
<section class="panel"><h2>VK</h2>
|
||||||
|
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
<section class="panel"><h2>Games</h2>
|
<section class="panel"><h2>Games</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
|
|||||||
@@ -156,13 +156,17 @@ type UserDetailView struct {
|
|||||||
HintBalance int
|
HintBalance int
|
||||||
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
||||||
// server's maxHintGrant), passed through so the policy value lives in one place.
|
// server's maxHintGrant), passed through so the policy value lives in one place.
|
||||||
HintGrantMax int
|
HintGrantMax int
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
HasStats bool
|
HasStats bool
|
||||||
Stats StatsRow
|
Stats StatsRow
|
||||||
Identities []IdentityRow
|
Identities []IdentityRow
|
||||||
Games []GameRow
|
Games []GameRow
|
||||||
|
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
||||||
|
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
||||||
|
// user id with a link to the VK profile (there is no VK messaging to drive).
|
||||||
TelegramID string
|
TelegramID string
|
||||||
|
VKID string
|
||||||
ConnectorEnabled bool
|
ConnectorEnabled bool
|
||||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||||
// time (min/mean/max), empty when the account has no timed move.
|
// time (min/mean/max), empty when the account has no timed move.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -42,6 +43,12 @@ type Config struct {
|
|||||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||||
// selects the development log mailer (the code is logged, not sent).
|
// selects the development log mailer (the code is logged, not sent).
|
||||||
SMTP account.SMTPConfig
|
SMTP account.SMTPConfig
|
||||||
|
// PublicBaseURL is the canonical public origin (scheme + host, e.g.
|
||||||
|
// https://erudit-game.ru) used to build absolute links in outgoing email — the
|
||||||
|
// confirm deeplink and the footer landing link. It is deliberately not derived
|
||||||
|
// from a request Host header, which would let an attacker inject a phishing link
|
||||||
|
// into the email. Required whenever an SMTP relay is configured.
|
||||||
|
PublicBaseURL string
|
||||||
// ConnectorAddr is the gRPC address of the Telegram platform connector
|
// ConnectorAddr is the gRPC address of the Telegram platform connector
|
||||||
// side-service, used by the admin console to send operator broadcasts. Empty
|
// side-service, used by the admin console to send operator broadcasts. Empty
|
||||||
// disables broadcasts (the admin broadcast actions report "not configured").
|
// disables broadcasts (the admin broadcast actions report "not configured").
|
||||||
@@ -51,6 +58,12 @@ type Config struct {
|
|||||||
// GuestRetention is the account age past which an unused guest (no game seat)
|
// GuestRetention is the account age past which an unused guest (no game seat)
|
||||||
// is eligible for deletion by the reaper.
|
// is eligible for deletion by the reaper.
|
||||||
GuestRetention time.Duration
|
GuestRetention time.Duration
|
||||||
|
// ExportSignKey signs the finished-game export download URLs. Empty leaves
|
||||||
|
// the export-URL endpoints disabled (503 on mint, 404 on download).
|
||||||
|
ExportSignKey string
|
||||||
|
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||||
|
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||||
|
RendererURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults applied when the corresponding environment variable is unset.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
@@ -135,6 +148,7 @@ func Load() (Config, error) {
|
|||||||
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
|
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
|
||||||
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
||||||
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
||||||
|
TLS: os.Getenv("BACKEND_SMTP_TLS"),
|
||||||
}
|
}
|
||||||
|
|
||||||
c := Config{
|
c := Config{
|
||||||
@@ -148,9 +162,12 @@ func Load() (Config, error) {
|
|||||||
Robot: rb,
|
Robot: rb,
|
||||||
RateWatch: rw,
|
RateWatch: rw,
|
||||||
SMTP: smtp,
|
SMTP: smtp,
|
||||||
|
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
|
||||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||||
GuestReapInterval: guestReapInterval,
|
GuestReapInterval: guestReapInterval,
|
||||||
GuestRetention: guestRetention,
|
GuestRetention: guestRetention,
|
||||||
|
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||||
|
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -195,6 +212,14 @@ func (c Config) validate() error {
|
|||||||
if c.GuestRetention <= 0 {
|
if c.GuestRetention <= 0 {
|
||||||
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
|
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
|
||||||
}
|
}
|
||||||
|
if c.SMTP.Host != "" {
|
||||||
|
if c.PublicBaseURL == "" {
|
||||||
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured")
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ var dictFiles = map[Variant]string{
|
|||||||
VariantErudit: "ru_erudit.dawg",
|
VariantErudit: "ru_erudit.dawg",
|
||||||
}
|
}
|
||||||
|
|
||||||
// entry is one resident dictionary: the loaded finder and the solver built over
|
// entry is one resident dictionary: the loaded finder, the solver built over it
|
||||||
// it. The finder is retained so Close can release it.
|
// and the file it was loaded from. The finder is retained so Close can release
|
||||||
|
// it; path is retained so the raw bytes can be re-read for the client download.
|
||||||
type entry struct {
|
type entry struct {
|
||||||
finder dawg.Finder
|
finder dawg.Finder
|
||||||
solver *scrabble.Solver
|
solver *scrabble.Solver
|
||||||
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry holds the dictionaries resident in memory, addressed by variant and
|
// Registry holds the dictionaries resident in memory, addressed by variant and
|
||||||
@@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error {
|
|||||||
if old, ok := r.entries[v][version]; ok {
|
if old, ok := r.entries[v][version]; ok {
|
||||||
_ = old.finder.Close()
|
_ = old.finder.Close()
|
||||||
}
|
}
|
||||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
|
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path}
|
||||||
r.latest[v] = version
|
r.latest[v] = version
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string {
|
|||||||
return versions
|
return versions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DictBytes returns the raw serialized DAWG for the (variant, version) pair,
|
||||||
|
// re-read from the file it was loaded from — the same immutable bytes the solver
|
||||||
|
// holds. It backs the client-side dictionary download for the local move
|
||||||
|
// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary
|
||||||
|
// is not resident, and wraps any read error. The file is read outside the lock.
|
||||||
|
func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
versions, ok := r.entries[v]
|
||||||
|
if !ok {
|
||||||
|
r.mu.RUnlock()
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||||
|
}
|
||||||
|
e, ok := versions[version]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(e.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup reports whether word is present in the (variant, version) dictionary,
|
// Lookup reports whether word is present in the (variant, version) dictionary,
|
||||||
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
||||||
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
||||||
|
|||||||
@@ -54,8 +54,14 @@ type Service struct {
|
|||||||
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
||||||
// best-effort and kept as a func so the game package never imports the social package.
|
// best-effort and kept as a func so the game package never imports the social package.
|
||||||
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
|
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
|
||||||
metrics *gameMetrics
|
// expireNudges, when set, marks every pending nudge in a game read once the game
|
||||||
log *zap.Logger
|
// finishes (the nudge badge is stale on a completed game). Unlike clearNudges it is
|
||||||
|
// keyed by game alone — it clears all seats' nudges, not one mover's — and runs on
|
||||||
|
// every completion path through commit. Best-effort; a func so the game package never
|
||||||
|
// imports the social package.
|
||||||
|
expireNudges func(ctx context.Context, gameID uuid.UUID) error
|
||||||
|
metrics *gameMetrics
|
||||||
|
log *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a Service. store and accounts wrap the same pool;
|
// NewService constructs a Service. store and accounts wrap the same pool;
|
||||||
@@ -107,6 +113,15 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account
|
|||||||
svc.clearNudges = fn
|
svc.clearNudges = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNudgeExpirer installs the hook that marks every pending nudge in a game read once the
|
||||||
|
// game finishes, on any completion path (a closing move, a resignation, a turn-timeout or a
|
||||||
|
// forfeit). It must be called during startup wiring; the default (nil) leaves a finished
|
||||||
|
// game's nudges to expire only when a recipient opens the move history or chat. The social
|
||||||
|
// package wires its ExpireNudges here. Chat messages are deliberately left unread.
|
||||||
|
func (svc *Service) SetNudgeExpirer(fn func(ctx context.Context, gameID uuid.UUID) error) {
|
||||||
|
svc.expireNudges = fn
|
||||||
|
}
|
||||||
|
|
||||||
// SetFirstMoveEntropy overrides the entropy source for the first-move draw
|
// SetFirstMoveEntropy overrides the entropy source for the first-move draw
|
||||||
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
|
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
|
||||||
// game is created; the production default is crypto/rand and is never overridden.
|
// game is created; the production default is crypto/rand and is never overridden.
|
||||||
@@ -699,6 +714,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
|||||||
}
|
}
|
||||||
if c.finished {
|
if c.finished {
|
||||||
svc.cache.remove(gameID)
|
svc.cache.remove(gameID)
|
||||||
|
// A finished game's nudges are stale, so clear them all here — every completion path
|
||||||
|
// funnels through commit (a closing move, a resignation, a forfeit or a turn-timeout),
|
||||||
|
// and only the move path also clears the mover's nudge on its own. Best-effort like
|
||||||
|
// clearNudges: the finish has committed, so a cleanup failure is logged, not surfaced.
|
||||||
|
// ExpireNudges leaves chat messages unread.
|
||||||
|
if svc.expireNudges != nil {
|
||||||
|
if err := svc.expireNudges(ctx, gameID); err != nil {
|
||||||
|
svc.log.Warn("expire nudges on game finish", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
post, err := svc.store.GetGame(ctx, gameID)
|
post, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1338,30 +1363,53 @@ func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDr
|
|||||||
return svc.store.SetupDraws(ctx, gameID)
|
return svc.store.SetupDraws(ctx, gameID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
|
// ExportView returns a finished game with its journal and per-seat display names —
|
||||||
// is allowed only on a finished game: exporting an in-progress game would leak the
|
// the material every export artifact (the GCG text, the PNG render payload) is built
|
||||||
// full move journal mid-play, so an active game yields ErrGameActive.
|
// from. It is allowed only on a finished game: exporting an in-progress game would
|
||||||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
// leak the full move journal mid-play, so an active game yields ErrGameActive. In an
|
||||||
|
// honest-AI game the robot seat is labelled "AI", not its pool name.
|
||||||
|
func (svc *Service) ExportView(ctx context.Context, gameID uuid.UUID) (Game, []HistoryMove, []string, error) {
|
||||||
g, err := svc.store.GetGame(ctx, gameID)
|
g, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return Game{}, nil, nil, err
|
||||||
}
|
}
|
||||||
if g.Status != StatusFinished {
|
if g.Status != StatusFinished {
|
||||||
return "", ErrGameActive
|
return Game{}, nil, nil, ErrGameActive
|
||||||
}
|
}
|
||||||
moves, err := svc.store.GetJournal(ctx, gameID)
|
moves, err := svc.store.GetJournal(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return Game{}, nil, nil, err
|
||||||
}
|
}
|
||||||
names := svc.seatNames(ctx, g)
|
names := svc.seatNames(ctx, g)
|
||||||
if g.VsAI {
|
if g.VsAI {
|
||||||
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
|
|
||||||
for _, s := range g.Seats {
|
for _, s := range g.Seats {
|
||||||
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
|
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
|
||||||
names[s.Seat] = aiPlayerName
|
names[s.Seat] = aiPlayerName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return g, moves, names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureExportable reports whether a game may be exported (it exists and is
|
||||||
|
// finished) without loading the journal — the export-URL mint check.
|
||||||
|
func (svc *Service) EnsureExportable(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
g, err := svc.store.GetGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if g.Status != StatusFinished {
|
||||||
|
return ErrGameActive
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
|
||||||
|
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||||
|
g, moves, names, err := svc.ExportView(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return writeGCG(g, names, moves), nil
|
return writeGCG(g, names, moves), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1440,13 +1488,24 @@ func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return svc.store.VoidGame(ctx, voidCommit{
|
if err := svc.store.VoidGame(ctx, voidCommit{
|
||||||
gameID: pre.ID,
|
gameID: pre.ID,
|
||||||
endReason: g.Reason().String(),
|
endReason: g.Reason().String(),
|
||||||
scores: scores,
|
scores: scores,
|
||||||
now: svc.clock(),
|
now: svc.clock(),
|
||||||
stats: buildStats(g, statSeats),
|
stats: buildStats(g, statSeats),
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A voided game is finished (as a draw) but bypasses commit, so clear its now-stale nudges
|
||||||
|
// here too. Best-effort, like the commit path: the void has persisted, so a cleanup failure
|
||||||
|
// is logged, not surfaced.
|
||||||
|
if svc.expireNudges != nil {
|
||||||
|
if err := svc.expireNudges(ctx, pre.ID); err != nil {
|
||||||
|
svc.log.Warn("expire nudges on voided game", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// replayMove re-applies one journalled move to g through the decoded engine API.
|
// replayMove re-applies one journalled move to g through the decoded engine API.
|
||||||
@@ -1613,6 +1672,14 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo
|
|||||||
return present, nil
|
return present, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DictBytes returns the raw serialized dictionary for the (variant, version) pair
|
||||||
|
// from the registry, backing the client-side dictionary download used by the
|
||||||
|
// local move preview. It surfaces engine.ErrUnknownVariant /
|
||||||
|
// engine.ErrUnknownVersion when that dictionary is not resident.
|
||||||
|
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
|
||||||
|
return svc.registry.DictBytes(variant, version)
|
||||||
|
}
|
||||||
|
|
||||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
||||||
// allowance plus the profile wallet.
|
// allowance plus the profile wallet.
|
||||||
func hintsRemaining(allowance, used, wallet int) int {
|
func hintsRemaining(allowance, used, wallet int) int {
|
||||||
|
|||||||
@@ -154,6 +154,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's
|
||||||
|
// language, display name and time zone from the launch fields / detected offset, records
|
||||||
|
// the vk identity as confirmed (a platform identity), and never overwrites an existing
|
||||||
|
// account on a later launch. It also exercises the widened identities.kind CHECK — a
|
||||||
|
// 'vk' row must insert.
|
||||||
|
func TestProvisionVKSeedsNewAccountOnly(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
ext := "vk-" + uuid.NewString()
|
||||||
|
|
||||||
|
acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision vk: %v", err)
|
||||||
|
}
|
||||||
|
if !created {
|
||||||
|
t.Error("created = false on first contact, want true")
|
||||||
|
}
|
||||||
|
if acc.PreferredLanguage != "ru" {
|
||||||
|
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
|
||||||
|
}
|
||||||
|
if acc.DisplayName != "Иван Петров" {
|
||||||
|
t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName)
|
||||||
|
}
|
||||||
|
if acc.TimeZone != "+03:00" {
|
||||||
|
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
|
||||||
|
}
|
||||||
|
// A VK identity is a platform identity: confirmed on insert.
|
||||||
|
if !identityConfirmed(t, account.KindVK, ext) {
|
||||||
|
t.Error("vk identity must be confirmed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later launch with different fields returns the same account, unchanged.
|
||||||
|
again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-provision vk: %v", err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
t.Error("created = true on a repeat launch, want false")
|
||||||
|
}
|
||||||
|
if again.ID != acc.ID {
|
||||||
|
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
|
||||||
|
}
|
||||||
|
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" {
|
||||||
|
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
|
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
|
||||||
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
|
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
|
||||||
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
|
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
|
||||||
|
|||||||
@@ -138,6 +138,65 @@ func TestNudgeClearedByMove(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGameCompletionExpiresNudgesKeepsChat checks that finishing a game by turn-timeout marks every
|
||||||
|
// pending nudge in it read — the lobby's nudge badge is stale once the game is over — while leaving
|
||||||
|
// real chat messages unread. It reproduces the reported bug: the timeout path commits the finish
|
||||||
|
// directly, bypassing the move path's per-mover nudge clear, so without a completion-driven expiry
|
||||||
|
// the awaited seat's nudge lingered as a badge on the finished game.
|
||||||
|
func TestGameCompletionExpiresNudgesKeepsChat(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
gameSvc := newGameService()
|
||||||
|
socialSvc := newSocialService()
|
||||||
|
gameSvc.SetNudgeExpirer(socialSvc.ExpireNudges)
|
||||||
|
|
||||||
|
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||||
|
g, err := gameSvc.Create(ctx, game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seat 1 nudges the to-move seat 0 (the awaited player), and seat 0 posts a real chat message,
|
||||||
|
// which seat 1 then holds unread. So before the timeout each side has exactly one unread entry:
|
||||||
|
// seat 0 a nudge, seat 1 a message — letting HasUnread isolate each kind.
|
||||||
|
if _, err := socialSvc.Nudge(ctx, g.ID, seats[1]); err != nil {
|
||||||
|
t.Fatalf("nudge: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := socialSvc.PostMessage(ctx, g.ID, seats[0], "good luck", ""); err != nil {
|
||||||
|
t.Fatalf("post message: %v", err)
|
||||||
|
}
|
||||||
|
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); !unread {
|
||||||
|
t.Fatal("seat 0 should hold the nudge unread before the timeout")
|
||||||
|
}
|
||||||
|
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
|
||||||
|
t.Fatal("seat 1 should hold the message unread before the timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age the turn past its deadline and time seat 0 out; an empty away window keeps this
|
||||||
|
// deterministic regardless of the wall clock. The sweep finishes the game through the direct
|
||||||
|
// commit path, never the move path.
|
||||||
|
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||||
|
setAway(t, seats[0], "UTC", "00:00", "00:00")
|
||||||
|
if n, err := gameSvc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||||||
|
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
|
||||||
|
}
|
||||||
|
if status, reason := gameStatus(t, gameSvc, g.ID); status != game.StatusFinished || reason != "timeout" {
|
||||||
|
t.Fatalf("game not timed out: status %q reason %q", status, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nudge is stale on a finished game and must be cleared; the chat message must survive.
|
||||||
|
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); unread {
|
||||||
|
t.Error("the nudge should be expired once the game has finished")
|
||||||
|
}
|
||||||
|
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
|
||||||
|
t.Error("a real chat message must stay unread after the game finishes")
|
||||||
|
}
|
||||||
|
if msg, _ := socialSvc.HasUnreadMessage(ctx, g.ID, seats[1]); !msg {
|
||||||
|
t.Error("the chat message must remain flagged as an unread message after completion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled
|
// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled
|
||||||
// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the
|
// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the
|
||||||
// chat, so the message must not linger unread (skewing the count and the read metric).
|
// chat, so the message must not linger unread (skewing the count and the read metric).
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
// recover the confirm-code from the body.
|
// recover the confirm-code from the body.
|
||||||
type capturingMailer struct{ lastBody string }
|
type capturingMailer struct{ lastBody string }
|
||||||
|
|
||||||
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error {
|
func (m *capturingMailer) Send(_ context.Context, msg account.Message) error {
|
||||||
m.lastBody = body
|
m.lastBody = msg.Text
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ func TestEmailConfirmFlow(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(store, mailer)
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
email := "user-" + uuid.NewString() + "@example.com"
|
email := "user-" + uuid.NewString() + "@example.com"
|
||||||
@@ -67,7 +67,7 @@ func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(store, mailer)
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
owner := provisionAccount(t)
|
owner := provisionAccount(t)
|
||||||
email := "taken-" + uuid.NewString() + "@example.com"
|
email := "taken-" + uuid.NewString() + "@example.com"
|
||||||
@@ -89,7 +89,7 @@ func TestEmailCodeExpires(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(store, mailer)
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
email := "expire-" + uuid.NewString() + "@example.com"
|
email := "expire-" + uuid.NewString() + "@example.com"
|
||||||
@@ -111,7 +111,7 @@ func TestEmailTooManyAttempts(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(store, mailer)
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
email := "lock-" + uuid.NewString() + "@example.com"
|
email := "lock-" + uuid.NewString() + "@example.com"
|
||||||
@@ -203,10 +203,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
|||||||
func TestEmailLoginFlow(t *testing.T) {
|
func TestEmailLoginFlow(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
||||||
email := "login-" + uuid.NewString() + "@example.com"
|
email := "login-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00")
|
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login code: %v", err)
|
t.Fatalf("request login code: %v", err)
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A second login for the same email is the returning user: same account.
|
// A second login for the same email is the returning user: same account.
|
||||||
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil {
|
if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
|
||||||
t.Fatalf("second request: %v", err)
|
t.Fatalf("second request: %v", err)
|
||||||
}
|
}
|
||||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||||
@@ -244,3 +244,37 @@ func TestEmailLoginFlow(t *testing.T) {
|
|||||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEmailLoginProvisionsGuestUntilConfirmed covers the squat fix: an email-login
|
||||||
|
// account is a guest (reapable, so an abandoned never-confirmed login frees its
|
||||||
|
// address) until the code is confirmed, which promotes it to a durable account.
|
||||||
|
func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
email := "squat-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request login code: %v", err)
|
||||||
|
}
|
||||||
|
before, err := store.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get before confirm: %v", err)
|
||||||
|
}
|
||||||
|
if !before.IsGuest {
|
||||||
|
t.Error("an unconfirmed email-login account must be a guest so it is reapable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)); err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
after, err := store.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get after confirm: %v", err)
|
||||||
|
}
|
||||||
|
if after.IsGuest {
|
||||||
|
t.Error("confirming the login must clear the guest flag (promote to durable)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID
|
|||||||
|
|
||||||
func newLinkService(mailer account.Mailer) *link.Service {
|
func newLinkService(mailer account.Mailer) *link.Service {
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
emails := account.NewEmailService(store, mailer)
|
emails := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
|
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
|
||||||
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
|
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Admit the 'vk' platform identity (VK Mini App users) into the identities.kind check
|
||||||
|
-- constraint, alongside telegram/email/robot. Expand-contract: widening the allowed set
|
||||||
|
-- is backward-compatible, so a backend image rollback stays DB-safe — the older code
|
||||||
|
-- simply never writes a 'vk' row. The table shape is unchanged (only the CHECK), so the
|
||||||
|
-- generated go-jet model is not regenerated.
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
|
||||||
|
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'vk'::text, 'email'::text, 'robot'::text])));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
|
||||||
|
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'email'::text, 'robot'::text])));
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Package render is the backend's client for the internal image-render sidecar: it POSTs
|
||||||
|
// a finished game's export payload and receives the rasterized PNG. The sidecar runs the
|
||||||
|
// same drawing code the web client unit-tests (ui/src/lib/gameimage.ts on skia-canvas);
|
||||||
|
// authentication and the signed public URL live in the server layer — this client only
|
||||||
|
// carries bytes.
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxPNG caps a render response; a scale-2 export of the largest plausible game is well
|
||||||
|
// under 2 MiB, so anything bigger is a sidecar malfunction, not a valid image.
|
||||||
|
const maxPNG = 8 << 20
|
||||||
|
|
||||||
|
// Client calls the render sidecar over the internal network.
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a client for the sidecar at baseURL (e.g. http://renderer:8090). A render
|
||||||
|
// of a full game takes well under a second; the timeout leaves room for a cold start.
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
return &Client{base: baseURL, http: &http.Client{Timeout: 15 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render posts payload (the JSON request shape the sidecar consumes) and returns the PNG.
|
||||||
|
func (c *Client) Render(ctx context.Context, payload any) ([]byte, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/render", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("render: sidecar status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
png, err := io.ReadAll(io.LimitReader(resp.Body, maxPNG+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: read response: %w", err)
|
||||||
|
}
|
||||||
|
if len(png) > maxPNG {
|
||||||
|
return nil, fmt.Errorf("render: response exceeds %d bytes", maxPNG)
|
||||||
|
}
|
||||||
|
return png, nil
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ func TestStatusForError(t *testing.T) {
|
|||||||
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
||||||
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
||||||
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
||||||
|
"too many codes": {account.ErrTooManyRequests, http.StatusTooManyRequests, "too_many_requests"},
|
||||||
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
|
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
|
||||||
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
||||||
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newExportServer is the routing harness with a signing key installed, so the
|
||||||
|
// pre-service branches of the export endpoints are reachable.
|
||||||
|
func newExportServer() *Server {
|
||||||
|
return New(":0", Deps{
|
||||||
|
Sessions: &session.Service{},
|
||||||
|
Accounts: &account.Store{},
|
||||||
|
Games: &game.Service{},
|
||||||
|
ExportSignKey: "test-key",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignExportIsDeterministicAndTamperEvident(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
sig := s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен")
|
||||||
|
if sig != s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен") {
|
||||||
|
t.Fatal("same inputs produced different signatures")
|
||||||
|
}
|
||||||
|
for _, tampered := range []string{
|
||||||
|
s.signExport("g2", "png", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "gcg", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "124", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "en", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "ru", "UTC", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас"),
|
||||||
|
} {
|
||||||
|
if tampered == sig {
|
||||||
|
t.Fatal("tampered input produced the same signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportURLWithoutKeyIs503(t *testing.T) {
|
||||||
|
s := New(":0", Deps{Sessions: &session.Service{}, Accounts: &account.Store{}, Games: &game.Service{}})
|
||||||
|
rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+uuid.NewString()+"/export-url?kind=png", "",
|
||||||
|
map[string]string{"X-User-ID": uuid.NewString()})
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("mint without key = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportURLRejectsBadParams(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
uid := map[string]string{"X-User-ID": uuid.NewString()}
|
||||||
|
gid := uuid.NewString()
|
||||||
|
|
||||||
|
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=exe", "", uid); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad kind = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
long := strings.Repeat("x", maxLabelsLen+1)
|
||||||
|
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=png&t="+long, "", uid); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("oversized labels = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportDownloadRejectsBadSignatures(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
gid := uuid.NewString()
|
||||||
|
exp := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)
|
||||||
|
|
||||||
|
// A wrong signature never reaches the domain (404 before any service call).
|
||||||
|
url := "/api/v1/public/dl/" + gid + "/png?e=" + exp + "&s=forged"
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("forged signature = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid signature over an EXPIRED timestamp is refused the same way.
|
||||||
|
old := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)
|
||||||
|
url = "/api/v1/public/dl/" + gid + "/png?e=" + old + "&s=" + s.signExport(gid, "png", old, "", "", "")
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expired link = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown kind is refused before signature checks.
|
||||||
|
url = "/api/v1/public/dl/" + gid + "/exe?e=" + exp + "&s=x"
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("bad kind = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPayloadMapsTheExportView(t *testing.T) {
|
||||||
|
gid := uuid.New()
|
||||||
|
finished := time.Unix(1_782_997_629, 0)
|
||||||
|
g := game.Game{
|
||||||
|
ID: gid,
|
||||||
|
Variant: engine.VariantRussianScrabble,
|
||||||
|
Status: game.StatusFinished,
|
||||||
|
Players: 2,
|
||||||
|
FinishedAt: &finished,
|
||||||
|
Seats: []game.Seat{
|
||||||
|
{Seat: 0, Score: 42, IsWinner: true, DisplayName: "snapshot"},
|
||||||
|
{Seat: 1, Score: 30},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
moves := []game.HistoryMove{
|
||||||
|
{
|
||||||
|
Seat: 0, Action: "play", Dir: "H", MainRow: 7, MainCol: 4,
|
||||||
|
Tiles: []engine.TileRecord{{Row: 7, Col: 4, Letter: "ч", Blank: false}},
|
||||||
|
Words: []string{"чика"}, Score: 9, RunningTotal: 9,
|
||||||
|
},
|
||||||
|
{Seat: 1, Action: "exchange", Exchanged: []string{"а", "б"}, RunningTotal: 0},
|
||||||
|
}
|
||||||
|
names := []string{"Аня", "Боб"}
|
||||||
|
|
||||||
|
p, err := renderPayload(g, moves, names, "ru", "Europe/Moscow", "пас,обмен,сдался,таймаут", "erudit-game.ru")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderPayload: %v", err)
|
||||||
|
}
|
||||||
|
if p.Game.LastActivityUnix != finished.Unix() {
|
||||||
|
t.Fatalf("lastActivityUnix = %d, want %d", p.Game.LastActivityUnix, finished.Unix())
|
||||||
|
}
|
||||||
|
if p.Game.Seats[0].DisplayName != "Аня" || !p.Game.Seats[0].IsWinner {
|
||||||
|
t.Fatalf("seat 0 = %+v, want the resolved name and the winner flag", p.Game.Seats[0])
|
||||||
|
}
|
||||||
|
if p.Moves[0].Words[0] != "ЧИКА" || p.Moves[0].Tiles[0].Letter != "Ч" {
|
||||||
|
t.Fatalf("letters not upper-cased: %+v", p.Moves[0])
|
||||||
|
}
|
||||||
|
if p.Moves[1].Count != 2 {
|
||||||
|
t.Fatalf("exchange count = %d, want 2", p.Moves[1].Count)
|
||||||
|
}
|
||||||
|
if p.Labels["pass"] != "пас" || p.Labels["timeout"] != "таймаут" {
|
||||||
|
t.Fatalf("labels not mapped positionally: %v", p.Labels)
|
||||||
|
}
|
||||||
|
if len(p.Alphabet) == 0 || p.Hostname != "erudit-game.ru" || p.TimeZone != "Europe/Moscow" {
|
||||||
|
t.Fatalf("alphabet/hostname/zone missing: %d entries, host %q, tz %q", len(p.Alphabet), p.Hostname, p.TimeZone)
|
||||||
|
}
|
||||||
|
for _, a := range p.Alphabet {
|
||||||
|
if a.Letter != strings.ToUpper(a.Letter) {
|
||||||
|
t.Fatalf("alphabet letter %q not upper-cased", a.Letter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ func (s *Server) registerRoutes() {
|
|||||||
if s.sessions != nil && s.accounts != nil {
|
if s.sessions != nil && s.accounts != nil {
|
||||||
in := s.internal
|
in := s.internal
|
||||||
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
||||||
|
in.POST("/sessions/vk", s.handleVKAuth)
|
||||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||||
@@ -86,9 +87,17 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||||
u.GET("/games/:id/history", s.handleHistory)
|
u.GET("/games/:id/history", s.handleHistory)
|
||||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||||
|
u.GET("/games/:id/export-url", s.handleExportURL)
|
||||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||||
u.POST("/games/:id/hide", s.handleHideGame)
|
u.POST("/games/:id/hide", s.handleHideGame)
|
||||||
|
// Raw dictionary download for the client-side local move preview, keyed by
|
||||||
|
// the game's pinned (variant, version); immutable, so cached hard.
|
||||||
|
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
||||||
|
// The signed finished-game export download — the only public data route:
|
||||||
|
// the URL's HMAC + expiry are the whole grant (export.go), because the
|
||||||
|
// platforms' native download calls carry no credentials.
|
||||||
|
s.public.GET("/dl/:id/:kind", s.handleExportDownload)
|
||||||
}
|
}
|
||||||
if s.feedback != nil {
|
if s.feedback != nil {
|
||||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||||
@@ -228,6 +237,8 @@ func statusForError(err error) (int, string) {
|
|||||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||||
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
|
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
|
||||||
return http.StatusUnauthorized, "code_invalid"
|
return http.StatusUnauthorized, "code_invalid"
|
||||||
|
case errors.Is(err, account.ErrTooManyRequests):
|
||||||
|
return http.StatusTooManyRequests, "too_many_requests"
|
||||||
case errors.Is(err, session.ErrNotFound):
|
case errors.Is(err, session.ErrNotFound):
|
||||||
return http.StatusUnauthorized, "session_invalid"
|
return http.StatusUnauthorized, "session_invalid"
|
||||||
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
||||||
|
|||||||
@@ -362,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
||||||
view.TelegramID = tg
|
view.TelegramID = tg
|
||||||
}
|
}
|
||||||
|
if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil {
|
||||||
|
view.VKID = vk
|
||||||
|
}
|
||||||
if games, err := s.games.ListForAccount(ctx, id); err == nil {
|
if games, err := s.games.ListForAccount(ctx, id); err == nil {
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
view.Games = append(view.Games, gameRow(g))
|
view.Games = append(view.Games, gameRow(g))
|
||||||
|
|||||||
@@ -65,6 +65,37 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
|||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
|
||||||
|
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
||||||
|
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
|
||||||
|
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
|
||||||
|
// offset) seeds its time zone. All seeds apply on first contact only.
|
||||||
|
type vkAuthRequest struct {
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
LanguageCode string `json:"language_code"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
|
||||||
|
// session for it, seeding a new account's language and display name from the supplied VK
|
||||||
|
// fields (first contact only). Unlike Telegram there is no moderated-chat re-evaluation
|
||||||
|
// or deep-link variant seed: a fresh VK account has no Telegram chat eligibility and the
|
||||||
|
// MVP carries no launch deep link.
|
||||||
|
func (s *Server) handleVKAuth(c *gin.Context) {
|
||||||
|
var req vkAuthRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||||
|
abortBadRequest(c, "external_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc, _, err := s.accounts.ProvisionVK(c.Request.Context(), req.ExternalID, req.LanguageCode, req.DisplayName, req.BrowserTZ)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mintSession(c, acc)
|
||||||
|
}
|
||||||
|
|
||||||
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
||||||
type pushTargetRequest struct {
|
type pushTargetRequest struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
@@ -141,6 +172,7 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
|
|||||||
type emailRequest struct {
|
type emailRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
Language string `json:"language"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
||||||
@@ -152,7 +184,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
|
|||||||
abortBadRequest(c, "email is required")
|
abortBadRequest(c, "email is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil {
|
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleDictBytes streams the raw serialized dictionary for a (variant, version)
|
||||||
|
// pair so the client can validate and score moves locally — the local move
|
||||||
|
// preview — instead of a network round trip per tile arrangement. It is reached
|
||||||
|
// only through the gateway's session-gated /dict route (which resolves the
|
||||||
|
// X-User-ID this group requires), and served with a long immutable cache lifetime
|
||||||
|
// because a published dictionary version never changes. An unknown variant or a
|
||||||
|
// version that is not resident is a 404.
|
||||||
|
func (s *Server) handleDictBytes(c *gin.Context) {
|
||||||
|
variant, err := engine.ParseVariant(c.Param("variant"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "unknown variant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := s.games.DictBytes(variant, c.Param("version"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "dictionary not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
@@ -96,6 +97,12 @@ type Deps struct {
|
|||||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||||
// them (notify.Nop).
|
// them (notify.Nop).
|
||||||
Notifier notify.Publisher
|
Notifier notify.Publisher
|
||||||
|
// ExportSignKey signs the finished-game export download URLs (export.go). An
|
||||||
|
// empty key leaves the export-URL endpoints answering 503/404.
|
||||||
|
ExportSignKey string
|
||||||
|
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||||
|
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||||
|
Renderer *render.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -124,6 +131,8 @@ type Server struct {
|
|||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
|
exportKey []byte
|
||||||
|
renderer *render.Client
|
||||||
|
|
||||||
public *gin.RouterGroup
|
public *gin.RouterGroup
|
||||||
user *gin.RouterGroup
|
user *gin.RouterGroup
|
||||||
@@ -173,8 +182,12 @@ func New(addr string, deps Deps) *Server {
|
|||||||
banview: deps.BanView,
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
|
renderer: deps.Renderer,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
}
|
}
|
||||||
|
if deps.ExportSignKey != "" {
|
||||||
|
s.exportKey = []byte(deps.ExportSignKey)
|
||||||
|
}
|
||||||
s.registerProbes(engine)
|
s.registerProbes(engine)
|
||||||
s.registerAPIGroups(engine)
|
s.registerAPIGroups(engine)
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExpireNudges marks every pending nudge in the game read, for when the game finishes: a nudge
|
||||||
|
// badge is stale once the game is over, so completion clears them all for every seat. Chat
|
||||||
|
// messages are left untouched (they stay unread). It satisfies game.NudgeExpirer and is wired
|
||||||
|
// into the completion path; failures are the caller's to log (the game has already finished).
|
||||||
|
// Unlike ClearNudges it records no publish-to-read latency — a completion is an expiry, not the
|
||||||
|
// recipient reading the nudge — so the latency metric is not skewed by games that end hours later.
|
||||||
|
func (svc *Service) ExpireNudges(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
ctx, span := svc.tracer.Start(ctx, "social.ExpireNudges",
|
||||||
|
trace.WithAttributes(attribute.String("game.id", gameID.String())))
|
||||||
|
defer span.End()
|
||||||
|
n, err := svc.store.expireNudges(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
span.SetAttributes(attribute.Int64("expired.count", n))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UnreadGames returns the set of games in which viewerID has at least one unread chat
|
// UnreadGames returns the set of games in which viewerID has at least one unread chat
|
||||||
// entry, for seeding the lobby's per-card unread badge in a single query.
|
// entry, for seeding the lobby's per-card unread badge in a single query.
|
||||||
func (svc *Service) UnreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
func (svc *Service) UnreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||||
@@ -140,6 +159,21 @@ RETURNING m.created_at`
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expireNudges marks every still-unread nudge in the game read for all seats at once, used when
|
||||||
|
// the game finishes. It returns the number of nudge entries it cleared. Only 'nudge' rows are
|
||||||
|
// touched, so chat messages keep their unread bits; it returns no post times because a completion
|
||||||
|
// is an expiry, not a player reading, and must not feed the publish-to-read latency metric.
|
||||||
|
func (s *Store) expireNudges(ctx context.Context, gameID uuid.UUID) (int64, error) {
|
||||||
|
const q = `UPDATE backend.chat_messages
|
||||||
|
SET unread_seats = 0
|
||||||
|
WHERE game_id = $1 AND kind = 'nudge' AND unread_seats <> 0`
|
||||||
|
res, err := s.db.ExecContext(ctx, q, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("social: expire nudges: %w", err)
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
// unreadGames returns the games where viewerID has an unread entry, resolving their
|
// unreadGames returns the games where viewerID has an unread entry, resolving their
|
||||||
// seat per game through the game_players join.
|
// seat per game through the game_players join.
|
||||||
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ DICT_VERSION=v1.3.0
|
|||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# --- Finished-game export ----------------------------------------------------
|
||||||
|
# HMAC key signing the public export download URLs (/dl/*). Required; generate a
|
||||||
|
# real contour value with `openssl rand -base64 32` (Gitea TEST_/PROD_EXPORT_SIGN_KEY).
|
||||||
|
EXPORT_SIGN_KEY=dev-export-sign-key
|
||||||
|
|
||||||
|
# --- Transactional email (Selectel relay) -----------------------------------
|
||||||
|
# Confirm-code email via the shared Selectel relay (one relay for every contour;
|
||||||
|
# limit 100 msgs / 5 min). Empty SMTP_RELAY_HOST makes the backend log codes instead
|
||||||
|
# of sending (dev). Port 465 = implicit TLS, else STARTTLS; no client cert is needed.
|
||||||
|
# TLS is implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it
|
||||||
|
# (ssl|starttls) — required for Selectel's non-standard ports (1127 = SSL, 1126 =
|
||||||
|
# STARTTLS). FROM must use the prod domain (Selectel only accepts the verified sender
|
||||||
|
# domain), so it is the same on every contour; a display name is fine
|
||||||
|
# (`"Игра Эрудит" <no-reply@erudit-game.ru>`). PUBLIC_BASE_URL is the canonical https
|
||||||
|
# origin for links in the email — the contour's own public URL. Gitea
|
||||||
|
# TEST_/PROD_SMTP_RELAY_* + TEST_/PROD_PUBLIC_BASE_URL.
|
||||||
|
SMTP_RELAY_HOST=
|
||||||
|
SMTP_RELAY_PORT=465
|
||||||
|
SMTP_RELAY_TLS= # empty = auto by port; ssl | starttls to force
|
||||||
|
SMTP_RELAY_USER= # secret
|
||||||
|
SMTP_RELAY_PASS= # secret
|
||||||
|
SMTP_RELAY_FROM=no-reply@erudit-game.ru
|
||||||
|
PUBLIC_BASE_URL= # required when SMTP_RELAY_HOST is set (e.g. https://erudit-game.ru)
|
||||||
|
|
||||||
# --- Edge / caddy -----------------------------------------------------------
|
# --- Edge / caddy -----------------------------------------------------------
|
||||||
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
||||||
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
||||||
@@ -32,6 +56,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
|||||||
VITE_TELEGRAM_BOT_ID=
|
VITE_TELEGRAM_BOT_ID=
|
||||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||||
|
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||||
VITE_GATEWAY_URL=
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
# --- Grafana ----------------------------------------------------------------
|
# --- Grafana ----------------------------------------------------------------
|
||||||
@@ -55,3 +80,9 @@ TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a vari
|
|||||||
TELEGRAM_MINIAPP_URL= # required
|
TELEGRAM_MINIAPP_URL= # required
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|
||||||
|
# --- VK Mini App ------------------------------------------------------------
|
||||||
|
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||||
|
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||||
|
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||||
|
GATEWAY_VK_APP_SECRET=
|
||||||
|
|||||||
+18
-5
@@ -16,6 +16,7 @@ operational reference for **every environment variable**.
|
|||||||
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. |
|
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. |
|
||||||
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
|
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
|
||||||
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
|
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
|
||||||
|
| `renderer` | built (`renderer/Dockerfile`) | Finished-game image-render sidecar (Node + skia-canvas running the shared `ui/src/lib/gameimage.ts`); internal-only HTTP at `renderer:8090`, called by the backend for the PNG export artifact. |
|
||||||
| `validator` | built (`platform/telegram/Dockerfile`, target `validator`) | Telegram HMAC validator (no VPN, no Bot API); internal gRPC at `validator:9091`. Game login depends only on this. |
|
| `validator` | built (`platform/telegram/Dockerfile`, target `validator`) | Telegram HMAC validator (no VPN, no Bot API); internal gRPC at `validator:9091`. Game login depends only on this. |
|
||||||
| `vpn` + `bot` | sidecar + built (`platform/telegram/Dockerfile`, target `bot`) | Telegram bot, gated to the **`telegram-local`** profile; egresses through the AmneziaWG sidecar and dials the gateway bot-link (mTLS) at `gateway:9443`. The test contour activates the profile; the prod **main** host omits it and runs the bot standalone on its **own host** (`docker-compose.bot.yml`, no VPN — native Bot API egress). |
|
| `vpn` + `bot` | sidecar + built (`platform/telegram/Dockerfile`, target `bot`) | Telegram bot, gated to the **`telegram-local`** profile; egresses through the AmneziaWG sidecar and dials the gateway bot-link (mTLS) at `gateway:9443`. The test contour activates the profile; the prod **main** host omits it and runs the bot standalone on its **own host** (`docker-compose.bot.yml`, no VPN — native Bot API egress). |
|
||||||
| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
|
| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
|
||||||
@@ -62,6 +63,7 @@ compose binds from this directory.
|
|||||||
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
||||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
|
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
|
||||||
|
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||||
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||||
@@ -92,9 +94,17 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||||
|
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
|
| `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
|
| `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
|
| `SMTP_RELAY_TLS` | variable | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
||||||
|
| `SMTP_RELAY_USER` | secret | _(empty)_ | Relay SMTP AUTH username. |
|
||||||
|
| `SMTP_RELAY_PASS` | secret | _(empty)_ | Relay SMTP AUTH password. |
|
||||||
|
| `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. |
|
||||||
|
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
|
||||||
|
|
||||||
The five `VITE_*` are **build-args** baked into the gateway and landing images at
|
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
||||||
build time (both targets share one UI build stage — keep the args identical so it is
|
build time (both targets share one UI build stage — keep the args identical so it is
|
||||||
built once), so changing them requires a rebuild (`--build`), not just a restart.
|
built once), so changing them requires a rebuild (`--build`), not just a restart.
|
||||||
|
|
||||||
@@ -105,7 +115,8 @@ at each other on the `internal` network — listed here so they are not mistaken
|
|||||||
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
|
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
|
||||||
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
||||||
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
|
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
|
||||||
bot-link relay `gateway:9092`), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
|
bot-link relay `gateway:9092`), `BACKEND_RENDERER_URL` (→ `renderer:8090`, the image-render
|
||||||
|
sidecar), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
|
||||||
mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
|
mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
|
||||||
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
|
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
|
||||||
deploy), and all services' `*_OTEL_*_EXPORTER=otlp` →
|
deploy), and all services' `*_OTEL_*_EXPORTER=otlp` →
|
||||||
@@ -193,12 +204,14 @@ players arrive.
|
|||||||
|
|
||||||
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
|
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
|
||||||
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
||||||
TELEGRAM_PROMO_BOT_TOKEN, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
||||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; variables:
|
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
|
||||||
|
SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables:
|
||||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
||||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
||||||
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME}`.
|
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK,
|
||||||
|
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL}`.
|
||||||
|
|
||||||
## Host-side setup (outside this repo)
|
## Host-side setup (outside this repo)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
|
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
|
||||||
# every operator surface under /_gm (the backend-rendered admin console and the
|
# every operator surface under /_gm (the backend-rendered admin console and the
|
||||||
# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to
|
# Grafana subpath); the game SPA (/app/, /telegram/, /vk/) and the Connect edge go to
|
||||||
# the gateway; the catch-all — notably the public landing at / — goes to the
|
# the gateway; the catch-all — notably the public landing at / — goes to the
|
||||||
# static landing container, so stray traffic never reaches the Go edge.
|
# static landing container, so stray traffic never reaches the Go edge.
|
||||||
# Mirrors ../galaxy-game's /_gm model.
|
# Mirrors ../galaxy-game's /_gm model.
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||||
@gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/*
|
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/*
|
||||||
handle @gateway {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081 {
|
reverse_proxy gateway:8081 {
|
||||||
header_up -X-Scrabble-Honeypot
|
header_up -X-Scrabble-Honeypot
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ services:
|
|||||||
limits:
|
limits:
|
||||||
memory: 384M
|
memory: 384M
|
||||||
|
|
||||||
|
renderer:
|
||||||
|
image: ${REGISTRY:?set REGISTRY}/scrabble-renderer:${TAG:?set TAG}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 160M
|
||||||
|
|
||||||
validator:
|
validator:
|
||||||
image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG}
|
image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG}
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -61,6 +61,36 @@ services:
|
|||||||
memory: 512M
|
memory: 512M
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
|
|
||||||
|
# The finished-game image-render sidecar: internal-only, called by the backend for the
|
||||||
|
# PNG export artifact. It runs the same ui/src/lib/gameimage.ts the web client
|
||||||
|
# unit-tests, on skia-canvas (renderer/README.md). node:22-slim carries a shell, so —
|
||||||
|
# uniquely among the built services — it has a real container healthcheck the backend's
|
||||||
|
# depends_on gates on.
|
||||||
|
renderer:
|
||||||
|
container_name: scrabble-renderer
|
||||||
|
image: scrabble-renderer:latest
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: renderer/Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: ${APP_VERSION:-dev}
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
environment:
|
||||||
|
RENDERER_PORT: "8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
# A render peaks well under 100 MiB (a 2-MP canvas + skia); idle sits near 60 MiB.
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "1.0"
|
||||||
|
memory: 192M
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
container_name: scrabble-backend
|
container_name: scrabble-backend
|
||||||
image: scrabble-backend:latest
|
image: scrabble-backend:latest
|
||||||
@@ -80,9 +110,15 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
renderer:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
# search_path=backend matches the migrations (00001 creates the schema).
|
# search_path=backend matches the migrations (00001 creates the schema).
|
||||||
BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
|
BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
|
||||||
|
# The finished-game export: the render sidecar address + the HMAC key signing the
|
||||||
|
# public download URLs (deploy env: TEST_/PROD_EXPORT_SIGN_KEY).
|
||||||
|
BACKEND_RENDERER_URL: http://renderer:8090
|
||||||
|
BACKEND_EXPORT_SIGN_KEY: ${EXPORT_SIGN_KEY:?set EXPORT_SIGN_KEY — the export download URL signing secret}
|
||||||
# The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom
|
# The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom
|
||||||
# for bursts. Postgres (2 cores / 512 MiB) handles it.
|
# for bursts. Postgres (2 cores / 512 MiB) handles it.
|
||||||
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
|
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
|
||||||
@@ -100,6 +136,21 @@ services:
|
|||||||
# GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the
|
# GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the
|
||||||
# cgroup quota (the runtime otherwise sees all of the host's cores).
|
# cgroup quota (the runtime otherwise sees all of the host's cores).
|
||||||
GOMAXPROCS: "2"
|
GOMAXPROCS: "2"
|
||||||
|
# Transactional email (confirm-codes) via the shared Selectel relay. An empty
|
||||||
|
# host makes the backend log codes instead of sending them (dev default). TLS is
|
||||||
|
# implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it
|
||||||
|
# (ssl|starttls) — needed for Selectel's non-standard ports (1127 = SSL, 1126 =
|
||||||
|
# STARTTLS). The From uses the prod domain (Selectel only accepts the verified
|
||||||
|
# sender domain), so it is the same on every contour. PUBLIC_BASE_URL is the
|
||||||
|
# canonical origin for links in the email (never a request Host header) —
|
||||||
|
# required whenever the relay host is set.
|
||||||
|
BACKEND_SMTP_HOST: ${SMTP_RELAY_HOST:-}
|
||||||
|
BACKEND_SMTP_PORT: ${SMTP_RELAY_PORT:-465}
|
||||||
|
BACKEND_SMTP_TLS: ${SMTP_RELAY_TLS:-}
|
||||||
|
BACKEND_SMTP_USERNAME: ${SMTP_RELAY_USER:-}
|
||||||
|
BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
|
||||||
|
BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost}
|
||||||
|
BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||||
# The dictionary lives on a named volume seeded from the image on first boot
|
# The dictionary lives on a named volume seeded from the image on first boot
|
||||||
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
||||||
# inherits). The admin console writes new version subdirectories here, and the
|
# inherits). The admin console writes new version subdirectories here, and the
|
||||||
@@ -134,6 +185,7 @@ services:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
|
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
||||||
@@ -147,6 +199,10 @@ services:
|
|||||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||||
# Telegram auth validates against the home validator (plaintext, internal).
|
# Telegram auth validates against the home validator (plaintext, internal).
|
||||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||||
|
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||||
|
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
||||||
|
# the VK auth path (auth.vk is then unregistered).
|
||||||
|
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
||||||
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
||||||
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
||||||
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
||||||
@@ -203,6 +259,7 @@ services:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
|
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"tags": ["scrabble"],
|
"tags": ["scrabble"],
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"schemaVersion": 39,
|
"schemaVersion": 39,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"refresh": "30s",
|
"refresh": "30s",
|
||||||
"time": { "from": "now-7d", "to": "now" },
|
"time": { "from": "now-7d", "to": "now" },
|
||||||
"panels": [
|
"panels": [
|
||||||
@@ -29,6 +29,33 @@
|
|||||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
"targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }]
|
"targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "App opens vs dictionary fills (cumulative)",
|
||||||
|
"description": "Local move-preview adoption. App opens are client cold starts; dictionary fills are IndexedDB downloads of a dawg. The gap between them is how many launches reuse an already-cached dictionary. Client-reported, best-effort (a batched beacon), so slightly lossy.",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "sum(local_eval_cold_start_total)", "legendFormat": "app opens (cold start)" },
|
||||||
|
{ "refId": "B", "expr": "sum(local_eval_dict_load_total{result=\"fetched\"})", "legendFormat": "dictionary fills (IndexedDB)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Dictionary loads by result (rate)",
|
||||||
|
"description": "Client dictionary loads for the local preview, by tier: fetched (network download), cache_hit (IndexedDB), miss (a warm-up that failed or timed out — trips the bad-connection breaker).",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_dict_load_total[1h])) by (result)", "legendFormat": "{{result}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Move previews by path (rate)",
|
||||||
|
"description": "Where the move preview is computed: local (on-device, the accelerator) vs network (the fallback to game.evaluate). The local share is the backend load shed.",
|
||||||
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ fi
|
|||||||
|
|
||||||
# Roll one service at a time, least -> most dependent; any failure rolls everything back.
|
# Roll one service at a time, least -> most dependent; any failure rolls everything back.
|
||||||
roll postgres health_postgres || { rollback; exit 1; }
|
roll postgres health_postgres || { rollback; exit 1; }
|
||||||
|
roll renderer health_running scrabble-renderer || { rollback; exit 1; }
|
||||||
roll backend health_backend || { rollback; exit 1; }
|
roll backend health_backend || { rollback; exit 1; }
|
||||||
roll gateway health_running scrabble-gateway || { rollback; exit 1; }
|
roll gateway health_running scrabble-gateway || { rollback; exit 1; }
|
||||||
roll landing health_landing || { rollback; exit 1; }
|
roll landing health_landing || { rollback; exit 1; }
|
||||||
|
|||||||
+119
-21
@@ -42,7 +42,12 @@ Three executables plus per-platform side-services:
|
|||||||
users, a weighted fair rotation — §10),
|
users, a weighted fair rotation — §10),
|
||||||
and a client **board-style** setting (bonus-label
|
and a client **board-style** setting (bonus-label
|
||||||
mode). The visual/interaction design system is documented in
|
mode). The visual/interaction design system is documented in
|
||||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
[`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally
|
||||||
|
tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area
|
||||||
|
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
|
||||||
|
Telegram's native **Settings** button into the in-app settings, and syncs the
|
||||||
|
device-independent display preferences (theme, reduce-motion, board labels — **not** the
|
||||||
|
interface language) across the user's Telegram devices via **CloudStorage**.
|
||||||
- **`platform/telegram`** — the Telegram side-service (module
|
- **`platform/telegram`** — the Telegram side-service (module
|
||||||
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
||||||
(**one bot**, one optional game channel, §3):
|
(**one bot**, one optional game channel, §3):
|
||||||
@@ -144,15 +149,23 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
|
|
||||||
- The gateway validates the originating credential **once** — Telegram `initData`
|
- The gateway validates the originating credential **once** — Telegram `initData`
|
||||||
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
|
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
|
||||||
the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
|
the HMAC secret — so it never reaches the gateway), a **VK Mini App** launch (verified
|
||||||
|
**in-process** by `gateway/internal/vkauth`: HMAC-SHA256 over the signed `vk_*` params
|
||||||
|
under the VK app's protected key `GATEWAY_VK_APP_SECRET`, base64url — a pure offline check,
|
||||||
|
as VK signing needs no API round-trip, so no side-service), an email-code login, or a guest
|
||||||
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
||||||
Telegram contact seeds the new account's language (from the launch `language_code`)
|
Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's
|
||||||
and display name (§4). The validator runs on the main host and never reaches the Bot
|
`vk_language`) and display name (§4; VK omits the name from the signed params, so the client
|
||||||
API, so login does not depend on Telegram or the remote bot being up (§10, §12).
|
reads it via `VKWebAppGetUserInfo` as an unsigned, cosmetic seed). The validator runs on the
|
||||||
|
main host and never reaches the Bot API, so login does not depend on Telegram or the remote
|
||||||
|
bot being up (§10, §12). VK launch params carry no built-in expiry (unlike Telegram's
|
||||||
|
`auth_date`), so freshness is not enforced — the minted session is the short-lived credential,
|
||||||
|
and a replay only re-authenticates the same `vk_user_id`.
|
||||||
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
||||||
game channel), split into a home **validator** and a remote **bot** that share the
|
game channel), split into a home **validator** and a remote **bot** that share the
|
||||||
token. `ValidateInitData` (the validator) validates `initData` against that single
|
token. `ValidateInitData` (the validator) validates `initData` against that single
|
||||||
token and returns only the Telegram user identity — there is no per-bot "service
|
token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram
|
||||||
|
user identity — there is no per-bot "service
|
||||||
language" and no supported-languages set on the wire. The bot's chat messages and
|
language" and no supported-languages set on the wire. The bot's chat messages and
|
||||||
out-of-app push are
|
out-of-app push are
|
||||||
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
|
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
|
||||||
@@ -219,9 +232,18 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
|
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
|
||||||
robot opponent (§7). The **email confirm-code flow** binds an email to the
|
robot opponent (§7). The **email confirm-code flow** binds an email to the
|
||||||
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
|
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
|
||||||
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
|
TTL, ≤ 5 attempts) is sent as a **branded HTML + plain-text email** through a
|
||||||
development log mailer when none is configured) and, once verified, attaches a
|
`Mailer` seam (go-mail over the shared relay — TLS chosen by port, implicit on
|
||||||
confirmed email identity. Accounts and identities use application-generated
|
465 and STARTTLS otherwise, or forced by config for a non-standard port; the
|
||||||
|
server certificate validated against the system roots, no client certificate; a
|
||||||
|
development log mailer when no relay is configured) and, once
|
||||||
|
verified, attaches a confirmed email identity. Sends are throttled per recipient
|
||||||
|
(a 60-second cooldown and a five-per-hour cap). Links in the email are built from
|
||||||
|
a configured public base URL, never a request Host header (anti-injection). An
|
||||||
|
**email-login** account is created flagged `is_guest` and stays reapable until the
|
||||||
|
code is confirmed — so an abandoned, never-confirmed login frees its reserved
|
||||||
|
address — with confirming clearing the flag. Accounts and identities use
|
||||||
|
application-generated
|
||||||
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
|
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
|
||||||
payment; no purchase flow yet) is carried on the account and ORed on a merge.
|
payment; no purchase flow yet) is carried on the account and ORed on a merge.
|
||||||
- **Linking** is initiated from an authenticated profile and proves
|
- **Linking** is initiated from an authenticated profile and proves
|
||||||
@@ -339,6 +361,17 @@ Key points:
|
|||||||
- History is dictionary-independent (§9.1): the engine emits decoded
|
- History is dictionary-independent (§9.1): the engine emits decoded
|
||||||
`MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard`
|
`MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard`
|
||||||
(alphabet only, no dictionary).
|
(alphabet only, no dictionary).
|
||||||
|
- The **client mirrors this validation path for an instant move preview** (the
|
||||||
|
local eval, `ui/src/lib/dict`): the dawg reader and the
|
||||||
|
validate/score/direction slice are ported to TypeScript, byte-for-byte against
|
||||||
|
this engine and pinned by the `conformance` CI job. Composing a move is scored
|
||||||
|
on-device with no round trip. It is an **advisory accelerator only** —
|
||||||
|
`submit_play` re-validates on the server, so a wrong or spoofed local result
|
||||||
|
cannot corrupt a game. The pinned per-game dictionary blob is fetched once
|
||||||
|
through a **session-gated `GET /dict/{variant}/{version}`** edge route
|
||||||
|
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
|
||||||
|
miss, storage eviction or a bad-connection breaker falls back to the network
|
||||||
|
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
|
||||||
|
|
||||||
## 6. Game rules
|
## 6. Game rules
|
||||||
|
|
||||||
@@ -633,7 +666,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
|
|||||||
robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player **opens the move history or the chat**
|
robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player **opens the move history or the chat**
|
||||||
(`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a
|
(`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a
|
||||||
history open is not a constant backend call), and a **nudge additionally clears when its
|
history open is not a constant backend call), and a **nudge additionally clears when its
|
||||||
recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is
|
recipient answers by moving** (the move path calls a wired `NudgeClearer`); and **every nudge in
|
||||||
|
a game is marked read when that game finishes** — on any completion path (a closing move, a
|
||||||
|
resignation, a forfeit or a turn-timeout, all funnelling through the shared `commit`), since the
|
||||||
|
nudge badge is stale once the game is over (a wired `NudgeExpirer`; chat messages stay unread and
|
||||||
|
this expiry records no read latency). The mask is
|
||||||
inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer
|
inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer
|
||||||
`unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin
|
`unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin
|
||||||
unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`**
|
unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`**
|
||||||
@@ -643,8 +680,9 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
|
|||||||
REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge
|
REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge
|
||||||
event raises only `unread_chat`, a message event raises both). The lobby additionally
|
event raises only `unread_chat`, a message event raises both). The lobby additionally
|
||||||
**floats games with any unread entry to the top** of the your-turn and opponent-turn
|
**floats games with any unread entry to the top** of the your-turn and opponent-turn
|
||||||
sections (the finished section keeps its activity order). On each clear the publish-to-read
|
sections (the finished section keeps its activity order). On each player-driven clear the
|
||||||
latency is recorded; the read time itself is not retained.
|
publish-to-read latency is recorded — the completion expiry records none (it is not a read);
|
||||||
|
the read time itself is not retained.
|
||||||
- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
|
- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
|
||||||
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
|
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
|
||||||
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
|
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
|
||||||
@@ -756,9 +794,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`
|
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||||||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||||||
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
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`
|
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
|
||||||
otherwise), so an in-progress journal is never leaked mid-play; the client
|
otherwise), so an in-progress journal is never leaked mid-play.
|
||||||
shares the `.gcg` file via the Web Share API where available, else downloads it.
|
|
||||||
|
**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
|
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 —
|
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||||
@@ -924,6 +993,20 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
distinct accounts that performed an authenticated edge action in the window. The
|
distinct accounts that performed an authenticated edge action in the window. The
|
||||||
gauge is single-process by design (single-instance MVP, §10): it is correct for one
|
gauge is single-process by design (single-instance MVP, §10): it is correct for one
|
||||||
gateway, resets on restart, and is a live operational figure, not a billing count.
|
gateway, resets on restart, and is a live operational figure, not a billing count.
|
||||||
|
- **Local-preview adoption (client-reported):** three gateway counters measure uptake of
|
||||||
|
the local move-preview accelerator (§5), fed by a small, best-effort client beacon
|
||||||
|
(`POST /metrics/local-eval`, session-gated) that batches counter deltas and posts them
|
||||||
|
on a 60 s timer and when the app is backgrounded — **never on the gameplay path** (the
|
||||||
|
in-app counters are plain in-memory increments; only the periodic flush touches the
|
||||||
|
network, fire-and-forget). `local_eval_cold_start_total` counts app cold starts (the
|
||||||
|
adoption denominator); `local_eval_dict_load_total` (`result` = fetched/cache_hit/miss)
|
||||||
|
splits a dawg download that fills IndexedDB from a cache hit and from a warm-up that
|
||||||
|
failed or timed out; `local_eval_preview_total` (`path` = local/network) is the share of
|
||||||
|
previews computed on-device versus the network `evaluate` fallback — the backend load
|
||||||
|
shed. It answers the owner's adoption question — how often the app opens versus how often
|
||||||
|
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
|
||||||
|
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
|
||||||
|
a game input.
|
||||||
- **Rate-limit observability:** every limiter rejection increments the gateway
|
- **Rate-limit observability:** every limiter rejection increments the gateway
|
||||||
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
||||||
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
||||||
@@ -972,7 +1055,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
| Concern | Enforced by |
|
| Concern | Enforced by |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
||||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
|
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
@@ -1035,24 +1118,39 @@ valid-code population). Brute-forcing a 6-digit friend code within these limits
|
|||||||
accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable);
|
accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable);
|
||||||
a dedicated redeem sub-limit or a longer code is the hardening step if abuse appears.
|
a dedicated redeem sub-limit or a longer code is the hardening step if abuse appears.
|
||||||
|
|
||||||
|
**Client source exposure.** The production UI build ships **no sourcemaps**
|
||||||
|
(`vite.config.ts` gates `build.sourcemap` off when `mode === 'production'`), so the gateway
|
||||||
|
and landing images serve only minified JS and the full TypeScript/Svelte source is not
|
||||||
|
recoverable from a served `.map` at the edge. Dev and the `mock` e2e build (`vite build
|
||||||
|
--mode mock`) keep maps for debugging. This is surface reduction, not secrecy — the single
|
||||||
|
minified bundle still carries all platform code paths and is reversible with effort.
|
||||||
|
|
||||||
## 13. Deployment (informational)
|
## 13. Deployment (informational)
|
||||||
|
|
||||||
Single public origin, path-routed. The Vite build has two entries: a lightweight
|
Single public origin, path-routed. The Vite build has two entries: a lightweight
|
||||||
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
|
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
|
||||||
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
|
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
|
||||||
`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
||||||
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
||||||
of redirecting away); a stray hit on the gateway's `/`
|
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
||||||
308-redirects to `/app/`. The **landing** ships in its own static container: the
|
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
|
||||||
|
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||||
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
|
static file serving and never reaches the Go edge. The landing shell carries the site's
|
||||||
|
static SEO head — Russian title/description, the Open Graph card (Telegram/VK link
|
||||||
|
previews), JSON-LD and a canonical link — with absolute URLs pinned to the production
|
||||||
|
origin, so the test contour canonicalises to production instead of being indexed as a
|
||||||
|
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
|
||||||
|
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||||
|
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
||||||
|
`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served
|
||||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||||
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
||||||
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
||||||
**admin console**; `/app/`, `/telegram/` and the Connect path go to the gateway; the
|
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
|
||||||
catch-all — notably the landing at `/` — goes to the landing container. The
|
catch-all — notably the landing at `/` — goes to the landing container. The
|
||||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||||
|
|||||||
+88
-14
@@ -16,24 +16,57 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge
|
|||||||
real-time in-app updates, switching interface language (en/ru) and theme, and a
|
real-time in-app updates, switching interface language (en/ru) and theme, and a
|
||||||
read-only profile. It also handles managing friends (including one-time friend
|
read-only profile. It also handles managing friends (including one-time friend
|
||||||
codes) and blocks, friend-game invitations, editing the profile and binding an
|
codes) and blocks, friend-game invitations, editing the profile and binding an
|
||||||
email, the statistics screen, and the in-game history viewer with GCG export.
|
email, the statistics screen, and the in-game history viewer with GCG / PNG-image export.
|
||||||
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
||||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||||
A public **landing page** at the site root introduces the game, switches language and
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
|
||||||
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
(it follows the system scheme, not the saved preference); its language choice is saved. The
|
||||||
|
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
|
||||||
|
render with arbitrary languages, so detection would make the indexed content
|
||||||
|
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
||||||
|
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
||||||
|
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
||||||
|
shell is `noindex` — the landing is the only indexable page.
|
||||||
|
|
||||||
|
### First-run onboarding
|
||||||
|
The first time a player opens the app it walks them through the interface with a light
|
||||||
|
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
|
||||||
|
tail — at a real control, and a **tap anywhere** advances to the next; after the last hint the
|
||||||
|
overlay is gone for good. Two independent series run. The **lobby** series points at the
|
||||||
|
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
|
||||||
|
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
|
||||||
|
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
|
||||||
|
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
|
||||||
|
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
|
||||||
|
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
|
||||||
|
bubbles re-anchor to wherever the controls move) and the hint texts are localized (en/ru). The
|
||||||
|
advertising banner hides while the overlay is up and returns when it closes.
|
||||||
|
|
||||||
### Identity & sessions
|
### Identity & sessions
|
||||||
A player arrives from a platform (Telegram first), via email login, or as an
|
A player arrives from a platform (Telegram first), via email login, or as an
|
||||||
ephemeral guest. The gateway validates the credential once and mints a thin
|
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||||||
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
||||||
the Telegram colours, and — on first contact — seeds the new account's interface
|
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
|
||||||
|
the device safe-area, and — on first contact — seeds the new account's interface
|
||||||
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
||||||
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
||||||
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
||||||
|
A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters
|
||||||
|
(verified by the gateway), and on first contact seeds the new account's interface language from
|
||||||
|
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
||||||
|
the name in the signed launch). While the theme preference is "auto" the app follows the VK
|
||||||
|
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
|
||||||
|
"couldn't load" screen applies inside VK.
|
||||||
|
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
|
||||||
|
(en/ru), no images — to the address; entering it signs the player in. A new address
|
||||||
|
is provisioned on the first request but only becomes a durable account once the code
|
||||||
|
is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address
|
||||||
|
freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap
|
||||||
|
per address), so a mistyped address or an impatient tap cannot flood an inbox.
|
||||||
Telegram runs a **single bot**: every player uses
|
Telegram runs a **single bot**: every player uses
|
||||||
the same bot, and all of its chat and out-of-app notifications are written in the
|
the same bot, and all of its chat and out-of-app notifications are written in the
|
||||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
@@ -120,7 +153,8 @@ and any incidental perpendicular words are ignored and not scored — while on i
|
|||||||
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
||||||
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
||||||
formed by inviting players from the friend list (an invitation, like a friend code,
|
formed by inviting players from the friend list (an invitation, like a friend code,
|
||||||
is shareable as a Telegram deep link that opens it directly): the inviter chooses the
|
is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the
|
||||||
|
Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the
|
||||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
expires after seven days.
|
expires after seven days.
|
||||||
|
|
||||||
@@ -141,7 +175,10 @@ tile that extends
|
|||||||
an existing word (down a column or across a row) is accepted. A play is validated
|
an existing word (down a column or across a row) is accepted. A play is validated
|
||||||
against the game's dictionary at submit time and scored; an unlimited preview
|
against the game's dictionary at submit time and scored; an unlimited preview
|
||||||
reports the word(s) a tentative move would form and its score, or that it is not
|
reports the word(s) a tentative move would form and its score, or that it is not
|
||||||
legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is
|
legal, and the move is offered for submission only once it is confirmed legal. The preview is
|
||||||
|
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
||||||
|
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
||||||
|
local dictionary is unavailable. The dictionary check tool is
|
||||||
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
||||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||||
English) to look it up. Hints are governed per game —
|
English) to look it up. Hints are governed per game —
|
||||||
@@ -238,7 +275,8 @@ entry — a message **or a nudge** from an opponent — raises a small **dot** n
|
|||||||
in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a
|
in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a
|
||||||
softer amber when only nudges are**. Opening the **move history** counts as reading
|
softer amber when only nudges are**. Opening the **move history** counts as reading
|
||||||
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
|
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
|
||||||
also clears the moment its recipient **takes their move**.
|
also clears the moment its recipient **takes their move**, and **all of a game's nudges clear once
|
||||||
|
the game finishes** (the badge is stale once the game is over) — but unread chat messages stay unread.
|
||||||
|
|
||||||
### Profile & settings
|
### Profile & settings
|
||||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||||
@@ -249,7 +287,10 @@ is first created — so robot games are timed correctly before you ever open thi
|
|||||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||||
merge".
|
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||||
|
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||||
|
reduce-motion — not the interface language, which follows your account) sync across your
|
||||||
|
Telegram devices.
|
||||||
|
|
||||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||||
@@ -283,11 +324,44 @@ silently ignores their messages) or clear a thread's messages. This is independe
|
|||||||
Feedback above — it serves people who write to the bot directly rather than through the app.
|
Feedback above — it serves people who write to the bot directly rather than through the app.
|
||||||
|
|
||||||
### History & statistics
|
### History & statistics
|
||||||
Finished games are archived in a dictionary-independent form and exportable to
|
Finished games are archived in a dictionary-independent form and exportable in
|
||||||
GCG; the export is offered **only once a game is finished**, and never for an
|
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
|
||||||
honest-AI practice game (a live game's export would leak the move journal; an AI
|
the final position; the export is offered **only once a game is finished**, and
|
||||||
game is throwaway). The client shares the `.gcg` file where the platform supports
|
never for an honest-AI practice game (a live game's export would leak the move
|
||||||
it, otherwise downloads it. Statistics (durable accounts only):
|
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 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 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
|
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
|
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
|
also shows the player's **move count** (their plays — passes and exchanges do not
|
||||||
|
|||||||
+93
-15
@@ -16,26 +16,63 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
профиль только для чтения. Он также включает управление друзьями (в т.ч.
|
профиль только для чтения. Он также включает управление друзьями (в т.ч.
|
||||||
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
|
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
|
||||||
редактирование профиля и привязку email, экран статистики и просмотр истории
|
редактирование профиля и привязку email, экран статистики и просмотр истории
|
||||||
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
|
партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток
|
||||||
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
|
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
|
||||||
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
|
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
|
||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
|
||||||
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
|
||||||
|
открывается на **русском** — язык браузера не определяется автоматически (роботы
|
||||||
|
рендерят страницу с произвольным языком, и автоопределение сделало бы
|
||||||
|
проиндексированный контент недетерминированным); сохранённый выбор 🌐 по-прежнему
|
||||||
|
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
||||||
|
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
||||||
|
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||||
|
`noindex` — индексируется только посадочная страница.
|
||||||
|
|
||||||
|
### Первый запуск: онбординг
|
||||||
|
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
||||||
|
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
||||||
|
«хвостиком» указывает на реальный элемент управления, а **тап в любом месте** переходит к
|
||||||
|
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
|
||||||
|
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
|
||||||
|
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
|
||||||
|
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
||||||
|
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
|
||||||
|
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
|
||||||
|
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
|
||||||
|
Обе серии одинаково работают в portrait и landscape (облачка переустанавливаются туда, куда
|
||||||
|
переезжают элементы), тексты подсказок локализованы (en/ru). Рекламный баннер скрывается, пока
|
||||||
|
оверлей показан, и возвращается по закрытию.
|
||||||
|
|
||||||
### Личность и сессии
|
### Личность и сессии
|
||||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
|
||||||
|
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
|
||||||
|
интерфейса нового аккаунта по
|
||||||
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
||||||
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
||||||
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
||||||
Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным
|
||||||
|
launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса
|
||||||
|
нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте,
|
||||||
|
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||||
|
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||||
|
тихого повтора «не удалось
|
||||||
|
загрузить» действует и внутри VK.
|
||||||
|
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||||
|
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||||
|
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||||
|
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
|
||||||
|
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
||||||
|
не завалили почтовый ящик.
|
||||||
|
Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
||||||
@@ -125,7 +162,8 @@ _Вход сейчас только через провайдера, поэто
|
|||||||
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||||
кто выбрал то же правило. Игры с друзьями (2–4)
|
кто выбрал то же правило. Игры с друзьями (2–4)
|
||||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
|
||||||
|
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
|
||||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||||
ответа приглашение протухает через семь дней.
|
ответа приглашение протухает через семь дней.
|
||||||
|
|
||||||
@@ -147,7 +185,9 @@ _Вход сейчас только через провайдера, поэто
|
|||||||
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||||
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
||||||
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
||||||
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
|
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
|
||||||
|
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
||||||
|
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. Подсказки управляются настройками
|
||||||
@@ -245,7 +285,8 @@ _Вход сейчас только через провайдера, поэто
|
|||||||
**лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую,
|
**лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую,
|
||||||
когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже
|
когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже
|
||||||
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
|
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
|
||||||
его получатель **делает ход**.
|
его получатель **делает ход**, и **все nudge'и партии гаснут по её завершении** (после конца
|
||||||
|
партии бейдж неактуален) — но непрочитанные сообщения чата остаются непрочитанными.
|
||||||
|
|
||||||
### Профиль и настройки
|
### Профиль и настройки
|
||||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||||
@@ -255,7 +296,11 @@ UTC; при создании аккаунта она подставляется
|
|||||||
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
||||||
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
|
||||||
|
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||||
|
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||||
|
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||||
|
Telegram.
|
||||||
|
|
||||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||||
@@ -289,11 +334,44 @@ UTC; при создании аккаунта она подставляется
|
|||||||
канал для тех, кто пишет боту напрямую, а не через приложение.
|
канал для тех, кто пишет боту напрямую, а не через приложение.
|
||||||
|
|
||||||
### История и статистика
|
### История и статистика
|
||||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
Завершённые партии архивируются в независимом от словаря виде и экспортируются в
|
||||||
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
|
||||||
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
|
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
|
||||||
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
|
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
|
||||||
иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на
|
||||||
|
Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов,
|
||||||
|
которым user activation не нужна) и собственный модал приложения в остальных
|
||||||
|
случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из
|
||||||
|
коллбека нативного попапа не открыть, а у VK нативного чузера нет.
|
||||||
|
|
||||||
|
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
|
||||||
|
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
|
||||||
|
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
|
||||||
|
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
|
||||||
|
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
|
||||||
|
строке на ход: классическая координата главного слова (горизонтальный ход —
|
||||||
|
цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и
|
||||||
|
его очки; дополнительные слова мультисловного хода — второй мелкой строкой,
|
||||||
|
не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а
|
||||||
|
замыкающая строка ± показывает концовочную поправку за рэк, когда она была
|
||||||
|
(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта
|
||||||
|
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
|
||||||
|
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
|
||||||
|
|
||||||
|
Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
|
||||||
|
платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
|
||||||
|
ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
|
||||||
|
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
|
||||||
|
скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный
|
||||||
|
share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в
|
||||||
|
нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер
|
||||||
|
(десктопный iframe VK — обычный браузер — скачивает оба); **мобильный браузер
|
||||||
|
получает системную share-шторку** (ничего не оседает в Загрузках); десктопный
|
||||||
|
браузер скачивает файл. Ссылка не требует логина при
|
||||||
|
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
|
||||||
|
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
|
||||||
|
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
|
||||||
|
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
|
||||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||||
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||||
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||||
|
|||||||
+23
-4
@@ -17,10 +17,26 @@ tests or touching CI.
|
|||||||
- **UI** — Vitest (unit) + Playwright
|
- **UI** — Vitest (unit) + Playwright
|
||||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||||
derivation and the GCG share/download choice, plus Playwright specs against the
|
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
|
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
|
invitations section, the stats screen, profile editing, and the export chooser's
|
||||||
finished-only visibility.
|
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
|
||||||
|
golden vectors from the release dictionaries — every stored word plus a battery of
|
||||||
|
plays across both cross-word rules and all variants, each with the engine's
|
||||||
|
legality, score, words and inferred direction. The gated Vitest suites
|
||||||
|
(`ui/src/lib/dict/*.parity.test.ts`, skipped without their `DICT_*` env) replay
|
||||||
|
them and must agree exactly; the `conformance` CI job runs the whole loop, so a
|
||||||
|
drift in the port fails the merge.
|
||||||
- **Engine** — correctness of scoring and move generation is owned
|
- **Engine** — correctness of scoring and move generation is owned
|
||||||
by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on
|
by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on
|
||||||
top of the embedded solver: per-variant smoke tests (load all three committed
|
top of the embedded solver: per-variant smoke tests (load all three committed
|
||||||
@@ -53,7 +69,10 @@ tests or touching CI.
|
|||||||
content and block-visibility rules, the nudge turn/rate-limit rules, the
|
content and block-visibility rules, the nudge turn/rate-limit rules, the
|
||||||
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
|
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
|
||||||
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
|
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
|
||||||
email, expiry and attempt-cap) with a fixture mailer. It also covers the
|
email, expiry and attempt-cap, and the guest-until-confirmed login) with a fixture
|
||||||
|
mailer. The branded email template render (en/ru subject, code and body) and the
|
||||||
|
per-recipient send-rate limiter (cooldown + hourly cap) are pure account-package
|
||||||
|
unit tests, needing no database. It also covers the
|
||||||
**befriend-an-opponent** gate (a request needs a shared game), the **permanent
|
**befriend-an-opponent** gate (a request needs a shared game), the **permanent
|
||||||
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
|
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
|
||||||
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
|
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
|
||||||
|
|||||||
+101
-11
@@ -49,6 +49,32 @@ fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade
|
|||||||
dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts`
|
dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts`
|
||||||
(unit-tested); `Splash.svelte` is the renderer.
|
(unit-tested); `Splash.svelte` is the renderer.
|
||||||
|
|
||||||
|
## First-run onboarding coachmarks (`components/Coachmark.svelte`, `lib/coachmark.ts`)
|
||||||
|
|
||||||
|
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
|
||||||
|
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
|
||||||
|
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
|
||||||
|
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
|
||||||
|
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
|
||||||
|
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
|
||||||
|
a series is marked only after its **last** hint, so an interrupted player replays it from the start.
|
||||||
|
The lobby series gates on `app.splashDone`, the game series on its first target laying out; both defer
|
||||||
|
while a modal (`welcomeRedeem` / `staleInvite`) is open.
|
||||||
|
|
||||||
|
Each target carries a **`data-coach` attribute**, so the **same positioning engine anchors it in both
|
||||||
|
orientations** wherever the layout moves it (the tab bar to the landscape left panel, etc.). The
|
||||||
|
bubble points at the live `getBoundingClientRect()` of its target, **re-measuring each frame until the
|
||||||
|
geometry settles** (the route-change slide, the hidden-banner reflow, async fonts) and on resize /
|
||||||
|
rotation; a target absent in the current state is skipped. The bubble matches the in-game counter text
|
||||||
|
size (`0.85rem`, larger in landscape), wraps by word and never fills the width; its **tail** sits on
|
||||||
|
the edge facing the target, centred on it (clamped near a corner). The pure geometry (step lists,
|
||||||
|
`nextVisibleStep`, `placeBubble`) lives in `lib/coachmark.ts` (unit-tested); `Coachmark.svelte` is the
|
||||||
|
renderer. While the overlay is up the **advertising banner is hidden** (`app.coachActive`) so its
|
||||||
|
scroll does not run behind the scrim. The hidden DebugPanel offers a **Reset visited** control that
|
||||||
|
clears the flags so the walk-through replays on the next launch. In the **mock build** the overlay
|
||||||
|
does not auto-show (so it cannot block the Playwright smoke); `?coach` forces it on for the dedicated
|
||||||
|
e2e and the screenshots.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
||||||
@@ -74,7 +100,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
|||||||
square with an accent underline). A red count **badge** rides the icon's corner — on the
|
square with an accent underline). A red count **badge** rides the icon's corner — on the
|
||||||
lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
|
lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
|
||||||
keep their own lobby section), and on the Hint tab for the remaining count. No text
|
keep their own lobby section), and on the Hint tab for the remaining count. No text
|
||||||
selection on nav / tab-bar / buttons (`user-select: none`).
|
selection on nav / tab-bar / buttons (`user-select: none`), and no tap-highlight flash on any
|
||||||
|
tappable element (`-webkit-tap-highlight-color: transparent` on `#app`) — Android in-app WebViews
|
||||||
|
otherwise draw a momentary selection-like box on a clickable node on tap (seen on the header title
|
||||||
|
and the back chevron).
|
||||||
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
|
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
|
||||||
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
||||||
from the left (back). Transitions are local (so they do not play on first load) and
|
from the left (back). Transitions are local (so they do not play on first load) and
|
||||||
@@ -113,6 +142,22 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
|||||||
by a background suspend reconnects silently on return — the connection banner is
|
by a background suspend reconnects silently on return — the connection banner is
|
||||||
suppressed while hidden and for a short grace after resume (visibilitychange +
|
suppressed while hidden and for a short grace after resume (visibilitychange +
|
||||||
pageshow/pagehide + Telegram `activated`/`deactivated`).
|
pageshow/pagehide + Telegram `activated`/`deactivated`).
|
||||||
|
- **VK integration** (`lib/vk.ts`): inside the VK Mini App the **auto** theme follows the VK
|
||||||
|
client's light/dark (`VKWebAppUpdateConfig`), as the VK webview's `prefers-color-scheme` does not
|
||||||
|
track it; explicit light/dark prefs still win. The device safe-area comes from CSS
|
||||||
|
`env(safe-area-inset-*)` (the meta already sets `viewport-fit=cover`) **max'd** with the VK bridge
|
||||||
|
insets (`VKWebAppUpdateInsets`, needed on Android, where the VK webview exposes no `env()` inset),
|
||||||
|
so the layout clears the VK home bar. Share and copy route through the bridge (`VKWebAppShare` /
|
||||||
|
`VKWebAppCopyText`) because `navigator.share` / `clipboard` are absent in the desktop VK iframe.
|
||||||
|
Haptics fire the same set as Telegram via VK Bridge taptic (`VKWebAppTapticImpactOccurred` /
|
||||||
|
`…NotificationOccurred` / `…SelectionChanged`), through the shared `lib/haptics.ts` dispatcher; and
|
||||||
|
VK's **horizontal swipe-back** is disabled at launch (`VKWebAppSetSwipeSettings`) so it does not
|
||||||
|
fight the app's own edge-swipe-back and tile drag — parity with Telegram's disabled vertical
|
||||||
|
swipes, the app owning navigation through its back chevron. VK's **status bar** (and, on Android,
|
||||||
|
the action / navigation bars) is painted to the app theme via `VKWebAppSetViewSettings` on launch
|
||||||
|
and every theme change — parity with the Telegram chrome painting (`syncVKChrome` mirrors
|
||||||
|
`syncTelegramChrome`); the status-bar appearance follows the `--bg` token's luminance
|
||||||
|
(`appearanceForBg`).
|
||||||
|
|
||||||
## Tiles & board
|
## Tiles & board
|
||||||
|
|
||||||
@@ -137,7 +182,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
|||||||
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
|
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
|
||||||
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
||||||
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
||||||
one-finger drag pans. History also opens on a **tap of the score bar** and closes on a tap or
|
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
|
||||||
|
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
|
||||||
|
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
|
||||||
|
opens on a **tap of the score bar** and closes on a tap or
|
||||||
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
|
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
|
||||||
hint's placement, not
|
hint's placement, not
|
||||||
the top-left.
|
the top-left.
|
||||||
@@ -166,8 +214,7 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
|||||||
**Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬
|
**Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬
|
||||||
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
|
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
|
||||||
game with "multiple words per turn" off) centres a **"One word per turn"** label between
|
game with "multiple words per turn" off) centres a **"One word per turn"** label between
|
||||||
those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that
|
those two icons; standard games show no label. Each **opponent**'s card also gains two
|
||||||
yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two
|
|
||||||
edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
|
edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
|
||||||
disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
|
disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
|
||||||
swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while
|
swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while
|
||||||
@@ -243,7 +290,8 @@ the surroundings in the light theme and a touch lighter in the dark theme, mappe
|
|||||||
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
|
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
|
||||||
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
|
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
|
||||||
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
|
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
|
||||||
the profile to show or hide it in place.
|
the profile to show or hide it in place. It is also hidden while a first-run onboarding overlay is up
|
||||||
|
(`app.coachActive`), reappearing by this same condition once the overlay closes.
|
||||||
|
|
||||||
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
||||||
live outside the components, so navigating between screens (which remounts the view) **continues the
|
live outside the components, so navigating between screens (which remounts the view) **continues the
|
||||||
@@ -355,13 +403,41 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
|
lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
|
||||||
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
|
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
|
||||||
Safari.
|
Safari.
|
||||||
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
|
- **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); *Export GCG* (the 📤 in the history
|
(the word(s) and the move score, no running total); the 📤 in the history header appears
|
||||||
header) shares or downloads the `.gcg` file and appears only once the game is finished — and
|
only once the game is finished — never in an honest-AI game (throwaway practice) — and
|
||||||
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
|
opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
|
||||||
it closes the history drawer (portrait) and zooms the board out.
|
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
|
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||||
offers *Export GCG* (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
|
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
|
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||||
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
|
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
|
||||||
row is turn-driven: on your turn the message field shows until your one allowed message is
|
row is turn-driven: on your turn the message field shows until your one allowed message is
|
||||||
@@ -376,6 +452,20 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the
|
raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the
|
||||||
Info tab.
|
Info tab.
|
||||||
|
|
||||||
|
## Dictionary warm-up overlay (`components/DictWarmup.svelte`)
|
||||||
|
|
||||||
|
Opening a game whose local move-preview dictionary is not yet cached shows a brief,
|
||||||
|
non-dismissable warm-up overlay while it downloads (a returning player's cached
|
||||||
|
dictionary loads from IndexedDB in a few ms, so the flash-guard suppresses it then).
|
||||||
|
A darker-than-onboarding scrim covers
|
||||||
|
the board; centred on it, a large emoji cycles through a fixed playful sequence — one
|
||||||
|
per 500 ms tick (300 ms still, then a 200 ms clockwise spin with the current glyph
|
||||||
|
fading out and the next fading in), looping — under a constant "Loading…" caption.
|
||||||
|
Reduce-motion drops the spin to a plain cross-fade. A ~120 ms flash-guard suppresses
|
||||||
|
the overlay for a disk-cached dictionary that loads in a few ms; a cold (network) load
|
||||||
|
shows it until the dictionary is ready or a 5 s cap elapses, after which the preview
|
||||||
|
falls back to the network. See docs/ARCHITECTURE.md §5 (the local eval).
|
||||||
|
|
||||||
## Caveat
|
## Caveat
|
||||||
|
|
||||||
Emoji are rendered by the platform's system emoji font, so their exact look varies across
|
Emoji are rendered by the platform's system emoji font, so their exact look varies across
|
||||||
|
|||||||
+4
-8
@@ -23,18 +23,14 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
|
|||||||
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
|
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
|
||||||
ARG VITE_TELEGRAM_BOT_ID=
|
ARG VITE_TELEGRAM_BOT_ID=
|
||||||
ARG VITE_TELEGRAM_LINK=
|
ARG VITE_TELEGRAM_LINK=
|
||||||
ARG VITE_TELEGRAM_LINK_EN=
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
|
||||||
ARG VITE_TELEGRAM_LINK_RU=
|
ARG VITE_VK_APP_LINK=
|
||||||
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
|
|
||||||
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
|
|
||||||
ARG VITE_GATEWAY_URL=
|
ARG VITE_GATEWAY_URL=
|
||||||
ARG VITE_APP_VERSION=
|
ARG VITE_APP_VERSION=
|
||||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||||
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
|
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
||||||
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \
|
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
|
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
|
|
||||||
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -7,7 +7,7 @@ thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
|||||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||||
**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
|
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||||
in the deployed contour the front caddy owns `/_gm` (see
|
in the deployed contour the front caddy owns `/_gm` (see
|
||||||
@@ -29,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams)
|
|||||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||||
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
||||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
||||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
|
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/
|
||||||
```
|
```
|
||||||
|
|
||||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||||
@@ -54,7 +54,14 @@ them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). Wh
|
|||||||
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
|
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
|
||||||
is unset the bot channel (out-of-app push + admin relay) is disabled.
|
is unset the bot channel (out-of-app push + admin relay) is disabled.
|
||||||
|
|
||||||
The message-type catalog: `auth.telegram`, `auth.guest`,
|
`auth.vk` validates a VK Mini App launch **in-process** (`internal/vkauth`): it verifies the
|
||||||
|
signed `vk_*` launch parameters against the VK app's protected key (`GATEWAY_VK_APP_SECRET`) —
|
||||||
|
HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API
|
||||||
|
round-trip, then forwards the trusted `vk_user_id` (and the client-read display name, which VK omits
|
||||||
|
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
||||||
|
(`auth.vk` is unregistered).
|
||||||
|
|
||||||
|
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
||||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
||||||
live events
|
live events
|
||||||
@@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
|||||||
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
||||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
||||||
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
||||||
|
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
|
||||||
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
||||||
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
||||||
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
||||||
|
|||||||
@@ -190,10 +190,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
||||||
}
|
}
|
||||||
|
|
||||||
registry := transcode.NewRegistry(backend, validator)
|
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
Backend: backend,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
Tracker: tracker,
|
Tracker: tracker,
|
||||||
Banlist: banlist,
|
Banlist: banlist,
|
||||||
|
|||||||
@@ -201,6 +201,23 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VKAuth provisions/finds the VK account and mints a session, seeding a brand-new
|
||||||
|
// account's preferred language from languageCode (the vk_language hint), its display
|
||||||
|
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
||||||
|
// name from the signed launch params) and its time zone from browserTz (the client's
|
||||||
|
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
||||||
|
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) {
|
||||||
|
var out SessionResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||||
|
map[string]string{
|
||||||
|
"external_id": externalID,
|
||||||
|
"language_code": languageCode,
|
||||||
|
"display_name": displayName,
|
||||||
|
"browser_tz": browserTz,
|
||||||
|
}, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
||||||
// external_id (empty when they have no Telegram identity), preferred language, and
|
// external_id (empty when they have no Telegram identity), preferred language, and
|
||||||
// whether they confined notifications to the in-app stream.
|
// whether they confined notifications to the in-app stream.
|
||||||
@@ -466,6 +483,13 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []Pl
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DictBytes fetches the raw serialized dictionary for a (variant, version) pair,
|
||||||
|
// backing the client-side local move preview. It returns the blob and the
|
||||||
|
// backend's Cache-Control header, forwarded so the immutable blob is cached hard.
|
||||||
|
func (c *Client) DictBytes(ctx context.Context, userID, variant, version string) ([]byte, string, error) {
|
||||||
|
return c.getRaw(ctx, "/api/v1/user/dict/"+url.PathEscape(variant)+"/"+url.PathEscape(version), userID, "Cache-Control")
|
||||||
|
}
|
||||||
|
|
||||||
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
|
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
|
||||||
// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word.
|
// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word.
|
||||||
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
|
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The response structs and client methods mirror the backend's social,
|
// The response structs and client methods mirror the backend's social,
|
||||||
@@ -347,3 +348,28 @@ func (c *Client) ExportGCG(ctx context.Context, userID, gameID string) (GcgResp,
|
|||||||
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/gcg"), userID, "", nil, &out)
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/gcg"), userID, "", nil, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportURLResp is the minted signed download link for an export artifact.
|
||||||
|
type ExportURLResp struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportURL mints a signed download URL for a finished game's artifact (kind
|
||||||
|
// "png" or "gcg"). dateLocale and the localized non-play labels ride the URL so
|
||||||
|
// the server render matches the player's presentation.
|
||||||
|
func (c *Client) ExportURL(ctx context.Context, userID, gameID, kind, dateLocale, timeZone string, labels []string) (ExportURLResp, error) {
|
||||||
|
q := url.Values{"kind": {kind}}
|
||||||
|
if dateLocale != "" {
|
||||||
|
q.Set("dl", dateLocale)
|
||||||
|
}
|
||||||
|
if timeZone != "" {
|
||||||
|
q.Set("tz", timeZone)
|
||||||
|
}
|
||||||
|
if len(labels) > 0 {
|
||||||
|
q.Set("t", strings.Join(labels, ","))
|
||||||
|
}
|
||||||
|
var out ExportURLResp
|
||||||
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/export-url")+"?"+q.Encode(), userID, "", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,10 +39,18 @@ const backendMaxIdleConns = 512
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
conn *grpc.ClientConn
|
// dl serves the export downloads: the backend may wait on the render sidecar
|
||||||
push pushv1.PushClient
|
// for several seconds, so these calls get their own, longer deadline than the
|
||||||
|
// ordinary REST budget.
|
||||||
|
dl *http.Client
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
push pushv1.PushClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exportDownloadTimeout bounds one export-download fetch end to end (the backend's
|
||||||
|
// own sidecar budget is 15s).
|
||||||
|
const exportDownloadTimeout = 30 * time.Second
|
||||||
|
|
||||||
// New dials the backend push gRPC endpoint and prepares the REST client. The
|
// New dials the backend push gRPC endpoint and prepares the REST client. The
|
||||||
// backend lives on a trusted network segment, so the gRPC connection uses
|
// backend lives on a trusted network segment, so the gRPC connection uses
|
||||||
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
|
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
|
||||||
@@ -62,11 +70,39 @@ func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) {
|
|||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(httpURL, "/"),
|
baseURL: strings.TrimRight(httpURL, "/"),
|
||||||
http: &http.Client{Timeout: timeout, Transport: transport},
|
http: &http.Client{Timeout: timeout, Transport: transport},
|
||||||
|
dl: &http.Client{Timeout: exportDownloadTimeout, Transport: transport},
|
||||||
conn: conn,
|
conn: conn,
|
||||||
push: pushv1.NewPushClient(conn),
|
push: pushv1.NewPushClient(conn),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportDownload fetches a signed finished-game export artifact from the backend's
|
||||||
|
// public group, forwarding the caller's public Host for the image footer. It returns
|
||||||
|
// the bytes with the backend's Content-Type and Content-Disposition. rest is the
|
||||||
|
// public path suffix after /dl (e.g. "/<game>/<kind>?e=…&s=…").
|
||||||
|
func (c *Client) ExportDownload(ctx context.Context, rest, publicHost string) ([]byte, string, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/public/dl"+rest, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("backendclient: new request: %w", err)
|
||||||
|
}
|
||||||
|
if publicHost != "" {
|
||||||
|
req.Header.Set("X-Public-Host", publicHost)
|
||||||
|
}
|
||||||
|
resp, err := c.dl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("backendclient: GET export: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("backendclient: read export: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, "", "", parseAPIError(resp.StatusCode, data)
|
||||||
|
}
|
||||||
|
return data, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Disposition"), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Close releases the gRPC connection.
|
// Close releases the gRPC connection.
|
||||||
func (c *Client) Close() error { return c.conn.Close() }
|
func (c *Client) Close() error { return c.conn.Close() }
|
||||||
|
|
||||||
@@ -125,6 +161,34 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRaw performs one REST GET, returning the raw (un-decoded) response body and
|
||||||
|
// the value of respHeader (e.g. Cache-Control). userID, when non-empty, is
|
||||||
|
// forwarded as X-User-ID. A non-2xx response is returned as an *APIError. Unlike
|
||||||
|
// do it does not JSON-decode, so it carries a binary payload such as a dictionary
|
||||||
|
// blob.
|
||||||
|
func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([]byte, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("backendclient: new request: %w", err)
|
||||||
|
}
|
||||||
|
if userID != "" {
|
||||||
|
req.Header.Set("X-User-ID", userID)
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("backendclient: read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, "", parseAPIError(resp.StatusCode, data)
|
||||||
|
}
|
||||||
|
return data, resp.Header.Get(respHeader), nil
|
||||||
|
}
|
||||||
|
|
||||||
// parseAPIError extracts the backend's {error:{code,message}} envelope.
|
// parseAPIError extracts the backend's {error:{code,message}} envelope.
|
||||||
func parseAPIError(status int, data []byte) *APIError {
|
func parseAPIError(status int, data []byte) *APIError {
|
||||||
var env struct {
|
var env struct {
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ type Config struct {
|
|||||||
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
||||||
// Login Widget data. Empty disables the telegram auth path.
|
// Login Widget data. Empty disables the telegram auth path.
|
||||||
ValidatorAddr string
|
ValidatorAddr string
|
||||||
|
// VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the
|
||||||
|
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
|
||||||
|
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
||||||
|
VKAppSecret string
|
||||||
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
||||||
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||||
BotLink BotLinkConfig
|
BotLink BotLinkConfig
|
||||||
@@ -169,6 +173,7 @@ func Load() (Config, error) {
|
|||||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
SessionCacheMax: defaultSessionCacheMax,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
Abuse: DefaultAbuse(),
|
Abuse: DefaultAbuse(),
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ type serverMetrics struct {
|
|||||||
rateLimited metric.Int64Counter
|
rateLimited metric.Int64Counter
|
||||||
banned metric.Int64Counter
|
banned metric.Int64Counter
|
||||||
active *activeUsers
|
active *activeUsers
|
||||||
|
// Client-reported local move-preview adoption (see localEvalMetricsHandler).
|
||||||
|
localColdStart metric.Int64Counter
|
||||||
|
localDictLoad metric.Int64Counter
|
||||||
|
localPreview metric.Int64Counter
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
||||||
@@ -54,7 +58,12 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
b, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_abuse_banned_total")
|
b, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_abuse_banned_total")
|
||||||
}
|
}
|
||||||
m := &serverMetrics{edge: h, rateLimited: c, banned: b, active: newActiveUsers()}
|
m := &serverMetrics{
|
||||||
|
edge: h, rateLimited: c, banned: b, active: newActiveUsers(),
|
||||||
|
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
||||||
|
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
||||||
|
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
||||||
|
}
|
||||||
|
|
||||||
gauge, err := meter.Int64ObservableGauge("active_users",
|
gauge, err := meter.Int64ObservableGauge("active_users",
|
||||||
metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance)."))
|
metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance)."))
|
||||||
@@ -98,3 +107,40 @@ func (m *serverMetrics) recordRateLimited(ctx context.Context, class string) {
|
|||||||
func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
|
func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
|
||||||
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since
|
||||||
|
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
|
||||||
|
// dictionaries vs on-device previews.
|
||||||
|
type localEvalReport struct {
|
||||||
|
ColdStart int `json:"cold_start"`
|
||||||
|
DictFetched int `json:"dict_fetched"`
|
||||||
|
DictCacheHit int `json:"dict_cache_hit"`
|
||||||
|
DictMiss int `json:"dict_miss"`
|
||||||
|
PreviewLocal int `json:"preview_local"`
|
||||||
|
PreviewNetwork int `json:"preview_network"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordLocalEval folds one client report into the edge's local-eval counters.
|
||||||
|
func (m *serverMetrics) recordLocalEval(ctx context.Context, r localEvalReport) {
|
||||||
|
add := func(c metric.Int64Counter, n int, opts ...metric.AddOption) {
|
||||||
|
if n > 0 {
|
||||||
|
c.Add(ctx, int64(n), opts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(m.localColdStart, r.ColdStart)
|
||||||
|
add(m.localDictLoad, r.DictFetched, metric.WithAttributes(attribute.String("result", "fetched")))
|
||||||
|
add(m.localDictLoad, r.DictCacheHit, metric.WithAttributes(attribute.String("result", "cache_hit")))
|
||||||
|
add(m.localDictLoad, r.DictMiss, metric.WithAttributes(attribute.String("result", "miss")))
|
||||||
|
add(m.localPreview, r.PreviewLocal, metric.WithAttributes(attribute.String("path", "local")))
|
||||||
|
add(m.localPreview, r.PreviewNetwork, metric.WithAttributes(attribute.String("path", "network")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// counterOf builds an Int64Counter on meter, falling back to a no-op on the rare
|
||||||
|
// construction error so metrics never fail startup.
|
||||||
|
func counterOf(meter metric.Meter, name, desc string) metric.Int64Counter {
|
||||||
|
c, err := meter.Int64Counter(name, metric.WithDescription(desc))
|
||||||
|
if err != nil {
|
||||||
|
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter(name)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
package connectsrv
|
package connectsrv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -68,6 +71,7 @@ const (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
registry *transcode.Registry
|
registry *transcode.Registry
|
||||||
sessions *session.Cache
|
sessions *session.Cache
|
||||||
|
backend *backendclient.Client
|
||||||
limiter *ratelimit.Limiter
|
limiter *ratelimit.Limiter
|
||||||
tracker *ratelimit.Tracker
|
tracker *ratelimit.Tracker
|
||||||
banlist *ratelimit.Banlist
|
banlist *ratelimit.Banlist
|
||||||
@@ -91,7 +95,10 @@ type Server struct {
|
|||||||
type Deps struct {
|
type Deps struct {
|
||||||
Registry *transcode.Registry
|
Registry *transcode.Registry
|
||||||
Sessions *session.Cache
|
Sessions *session.Cache
|
||||||
Limiter *ratelimit.Limiter
|
// Backend is the REST client backing the session-gated /dict blob route; a nil
|
||||||
|
// value disables that route (it 404s).
|
||||||
|
Backend *backendclient.Client
|
||||||
|
Limiter *ratelimit.Limiter
|
||||||
// Tracker accumulates limiter rejections for the periodic report; nil
|
// Tracker accumulates limiter rejections for the periodic report; nil
|
||||||
// selects a private tracker (rejections are then only counted, never
|
// selects a private tracker (rejections are then only counted, never
|
||||||
// reported).
|
// reported).
|
||||||
@@ -142,6 +149,7 @@ func NewServer(d Deps) *Server {
|
|||||||
return &Server{
|
return &Server{
|
||||||
registry: d.Registry,
|
registry: d.Registry,
|
||||||
sessions: d.Sessions,
|
sessions: d.Sessions,
|
||||||
|
backend: d.Backend,
|
||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
tracker: tracker,
|
tracker: tracker,
|
||||||
banlist: banlist,
|
banlist: banlist,
|
||||||
@@ -183,14 +191,21 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
// does not serve the app shell at the operator path.
|
// does not serve the app shell at the operator path.
|
||||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||||
}
|
}
|
||||||
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram
|
// The client-side local move preview pulls each game's pinned dictionary blob
|
||||||
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
|
// through this session-gated route (not public); see dictBytesHandler.
|
||||||
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
|
mux.Handle("/dict/", s.dictBytesHandler())
|
||||||
// each mount falls back to the app shell (index.html) for the hash router. The
|
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||||
// public landing lives in its own static container behind the contour caddy,
|
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||||
// so the catch-all redirects a stray root hit to the app shell — which
|
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||||
// keeps a local no-caddy run usable.
|
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||||
|
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
||||||
|
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
||||||
|
// priority, and each mount falls back to the app shell (index.html) for the hash
|
||||||
|
// router. The public landing lives in its own static container behind the contour
|
||||||
|
// caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
|
||||||
|
// local no-caddy run usable.
|
||||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||||
|
mux.Handle("/vk/", webui.Handler("/vk/", "index.html"))
|
||||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||||
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
||||||
@@ -396,6 +411,177 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dictBytesHandler serves the raw dictionary blob for a (variant, version) pair to
|
||||||
|
// a signed-in client (the local move preview), proxying the backend's authed
|
||||||
|
// endpoint. It is session-gated — not public — bounds the download with the
|
||||||
|
// user-class limiter, and forwards the backend's immutable Cache-Control so the
|
||||||
|
// browser caches the blob hard. Only GET is allowed; the path is
|
||||||
|
// /dict/{variant}/{version}.
|
||||||
|
// exportDownloadHandler serves the signed finished-game export downloads (/dl/*).
|
||||||
|
// It is the gateway's only unauthenticated data route: the platforms' native
|
||||||
|
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain anchor)
|
||||||
|
// carry no session, so the URL's HMAC — verified by the backend — is the whole
|
||||||
|
// grant. The gateway only rate-limits by IP and forwards, passing the public Host
|
||||||
|
// along for the image footer.
|
||||||
|
func (s *Server) exportDownloadHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.backend == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := peerIP(r.RemoteAddr, r.Header)
|
||||||
|
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
|
||||||
|
s.noteRateLimited(r.Context(), classPublic, ip, "export-dl")
|
||||||
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(r.URL.Path, "/dl")
|
||||||
|
if rest == "" || rest == "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
rest += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
data, contentType, disposition, err := s.backend.ExportDownload(r.Context(), rest, r.Host)
|
||||||
|
if err != nil {
|
||||||
|
var apiErr *backendclient.APIError
|
||||||
|
if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError {
|
||||||
|
http.NotFound(w, r) // invalid, expired or unknown link
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Warn("export download failed", zap.Error(err))
|
||||||
|
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
if disposition != "" {
|
||||||
|
w.Header().Set("Content-Disposition", disposition)
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=0")
|
||||||
|
// ServeContent (not a bare Write): the platforms' native downloaders are picky —
|
||||||
|
// Android's system DownloadManager (the VKWebAppDownloadFile executor) hangs on a
|
||||||
|
// chunked body of unknown length and may probe with Range. ServeContent emits
|
||||||
|
// Content-Length, honours Range/If-* and answers 206s from the buffered artifact.
|
||||||
|
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dictBytesHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.backend == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := peerIP(r.RemoteAddr, r.Header)
|
||||||
|
if !s.limiter.Allow("user:"+ip, s.userPolicy) {
|
||||||
|
s.noteRateLimited(r.Context(), classUser, ip, "dict")
|
||||||
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, _, err := s.resolve(r.Context(), r.Header, ip)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
variant, version, ok := parseDictPath(r.URL.Path)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, cacheControl, err := s.backend.DictBytes(r.Context(), uid, variant, version)
|
||||||
|
if err != nil {
|
||||||
|
var apiErr *backendclient.APIError
|
||||||
|
if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError {
|
||||||
|
http.NotFound(w, r) // unknown variant or version
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Warn("dict fetch failed", zap.Error(err))
|
||||||
|
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cacheControl != "" {
|
||||||
|
w.Header().Set("Cache-Control", cacheControl)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDictPath splits /dict/{variant}/{version}; both segments must be present
|
||||||
|
// and non-empty, and the version must not contain a further slash.
|
||||||
|
func parseDictPath(p string) (variant, version string, ok bool) {
|
||||||
|
rest, found := strings.CutPrefix(p, "/dict/")
|
||||||
|
if !found {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
i := strings.IndexByte(rest, '/')
|
||||||
|
if i <= 0 || i == len(rest)-1 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
variant, version = rest[:i], rest[i+1:]
|
||||||
|
if strings.Contains(version, "/") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return variant, version, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// localEvalMetricsHandler ingests a client's local move-preview telemetry batch into the
|
||||||
|
// edge's adoption counters (docs/ARCHITECTURE.md §11). Session-gated so only real clients
|
||||||
|
// report; the values are aggregate (no per-user attributes) and clamped against a spoofed
|
||||||
|
// inflation. Only POST; the body is a small JSON of per-metric deltas.
|
||||||
|
func (s *Server) localEvalMetricsHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := peerIP(r.RemoteAddr, r.Header)
|
||||||
|
if _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rep localEvalReport
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 512)).Decode(&rep); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clampReport(&rep)
|
||||||
|
s.metrics.recordLocalEval(r.Context(), rep)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampReport bounds each counter of a client report against a spoofed inflation — one batch
|
||||||
|
// reflects at most a few minutes of a single client's activity.
|
||||||
|
func clampReport(r *localEvalReport) {
|
||||||
|
const maxPerField = 1000
|
||||||
|
clamp := func(n *int) {
|
||||||
|
if *n < 0 {
|
||||||
|
*n = 0
|
||||||
|
} else if *n > maxPerField {
|
||||||
|
*n = maxPerField
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clamp(&r.ColdStart)
|
||||||
|
clamp(&r.DictFetched)
|
||||||
|
clamp(&r.DictCacheHit)
|
||||||
|
clamp(&r.DictMiss)
|
||||||
|
clamp(&r.PreviewLocal)
|
||||||
|
clamp(&r.PreviewNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
// resolve extracts and resolves the Authorization bearer token to an account id
|
// resolve extracts and resolves the Authorization bearer token to an account id
|
||||||
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||||
// or unknown.
|
// or unknown.
|
||||||
|
|||||||
@@ -251,6 +251,18 @@ func encodeInvitationList(r backendclient.InvitationListResp) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeExportURL builds an ExportUrl payload.
|
||||||
|
func encodeExportURL(r backendclient.ExportURLResp) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
path := b.CreateString(r.Path)
|
||||||
|
fn := b.CreateString(r.Filename)
|
||||||
|
fb.ExportUrlStart(b)
|
||||||
|
fb.ExportUrlAddPath(b, path)
|
||||||
|
fb.ExportUrlAddFilename(b, fn)
|
||||||
|
b.Finish(fb.ExportUrlEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
// encodeGcg builds a GcgExport payload.
|
// encodeGcg builds a GcgExport payload.
|
||||||
func encodeGcg(r backendclient.GcgResp) []byte {
|
func encodeGcg(r backendclient.GcgResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(1024)
|
b := flatbuffers.NewBuilder(1024)
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import (
|
|||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
"scrabble/gateway/internal/connector"
|
"scrabble/gateway/internal/connector"
|
||||||
|
"scrabble/gateway/internal/vkauth"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message types in the vertical slice.
|
// Message types in the vertical slice.
|
||||||
const (
|
const (
|
||||||
MsgAuthTelegram = "auth.telegram"
|
MsgAuthTelegram = "auth.telegram"
|
||||||
|
MsgAuthVK = "auth.vk"
|
||||||
MsgAuthGuest = "auth.guest"
|
MsgAuthGuest = "auth.guest"
|
||||||
MsgAuthEmailReq = "auth.email.request"
|
MsgAuthEmailReq = "auth.email.request"
|
||||||
MsgAuthEmailLogin = "auth.email.login"
|
MsgAuthEmailLogin = "auth.email.login"
|
||||||
@@ -89,8 +91,9 @@ type TelegramValidator interface {
|
|||||||
|
|
||||||
// NewRegistry builds the slice's message-type catalog over the backend client.
|
// NewRegistry builds the slice's message-type catalog over the backend client.
|
||||||
// The Telegram auth op is registered only when a validator is supplied (the
|
// The Telegram auth op is registered only when a validator is supplied (the
|
||||||
// connector is configured); otherwise auth.telegram is simply unknown.
|
// connector is configured); otherwise auth.telegram is simply unknown. Optional ops
|
||||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
|
// (e.g. WithVKAuth) are applied last from opts.
|
||||||
|
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Option) *Registry {
|
||||||
r := &Registry{ops: make(map[string]Op)}
|
r := &Registry{ops: make(map[string]Op)}
|
||||||
if tg != nil {
|
if tg != nil {
|
||||||
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
||||||
@@ -126,9 +129,27 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
|
|||||||
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
||||||
registerSocialOps(r, backend)
|
registerSocialOps(r, backend)
|
||||||
registerLinkOps(r, backend, tg)
|
registerLinkOps(r, backend, tg)
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(r, backend)
|
||||||
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option configures an optional registry operation at construction. It is kept out of
|
||||||
|
// NewRegistry's positional signature so existing call sites stay unaffected.
|
||||||
|
type Option func(r *Registry, backend *backendclient.Client)
|
||||||
|
|
||||||
|
// WithVKAuth registers the VK Mini App auth op (auth.vk), which verifies launch params
|
||||||
|
// in-process under the VK app secret. A blank secret leaves auth.vk unregistered, so
|
||||||
|
// the op is simply unknown wherever VK is not configured.
|
||||||
|
func WithVKAuth(secret string) Option {
|
||||||
|
return func(r *Registry, backend *backendclient.Client) {
|
||||||
|
if secret != "" {
|
||||||
|
r.ops[MsgAuthVK] = Op{Handler: authVKHandler(backend, secret)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup returns the operation for messageType, and whether it is registered.
|
// Lookup returns the operation for messageType, and whether it is registered.
|
||||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||||
op, ok := r.ops[messageType]
|
op, ok := r.ops[messageType]
|
||||||
@@ -146,6 +167,9 @@ func DomainCode(err error) (string, bool) {
|
|||||||
if errors.Is(err, connector.ErrInvalidInitData) {
|
if errors.Is(err, connector.ErrInvalidInitData) {
|
||||||
return "invalid_init_data", true
|
return "invalid_init_data", true
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, vkauth.ErrInvalid) {
|
||||||
|
return "invalid_vk_params", true
|
||||||
|
}
|
||||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||||
return "invalid_login_widget", true
|
return "invalid_login_widget", true
|
||||||
}
|
}
|
||||||
@@ -175,6 +199,25 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authVKHandler verifies a VK Mini App launch in-process (HMAC over the signed vk_*
|
||||||
|
// params under the app secret) and provisions/finds the bound account. Unlike Telegram,
|
||||||
|
// VK omits the user's name from the signed params, so the client-supplied display_name
|
||||||
|
// rides the wire as a cosmetic seed for a brand-new account.
|
||||||
|
func authVKHandler(backend *backendclient.Client, secret string) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsVKLoginRequest(req.Payload, 0)
|
||||||
|
user, err := vkauth.Verify(string(in.Params()), secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeSession(sess), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func authGuestHandler(backend *backendclient.Client) Handler {
|
func authGuestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
// The guest bootstrap historically carried no payload; the detected zone is
|
// The guest bootstrap historically carried no payload; the detected zone is
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
MsgProfileUpdate = "profile.update"
|
MsgProfileUpdate = "profile.update"
|
||||||
MsgStatsGet = "stats.get"
|
MsgStatsGet = "stats.get"
|
||||||
MsgGameGCG = "game.gcg"
|
MsgGameGCG = "game.gcg"
|
||||||
|
MsgGameExportURL = "game.export_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerSocialOps adds the social, account and history operations to the
|
// registerSocialOps adds the social, account and history operations to the
|
||||||
@@ -56,6 +57,7 @@ func registerSocialOps(r *Registry, backend *backendclient.Client) {
|
|||||||
r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true}
|
r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true}
|
||||||
r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true}
|
r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true}
|
r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgGameExportURL] = Op{Handler: exportURLHandler(backend), Auth: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- friends ---
|
// --- friends ---
|
||||||
@@ -290,6 +292,21 @@ func gcgHandler(backend *backendclient.Client) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportURLHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsExportUrlRequest(req.Payload, 0)
|
||||||
|
labels := make([]string, 0, in.ActionLabelsLength())
|
||||||
|
for i := range in.ActionLabelsLength() {
|
||||||
|
labels = append(labels, string(in.ActionLabels(i)))
|
||||||
|
}
|
||||||
|
res, err := backend.ExportURL(ctx, req.UserID, string(in.GameId()), string(in.Kind()), string(in.DateLocale()), string(in.TimeZone()), labels)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeExportURL(res), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest.
|
// decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest.
|
||||||
func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string {
|
func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string {
|
||||||
n := in.InviteeIdsLength()
|
n := in.InviteeIdsLength()
|
||||||
|
|||||||
@@ -263,6 +263,54 @@ func TestGcgRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportURLRoundTrip(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/user/games/g-1/export-url" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Get("kind") != "png" || q.Get("dl") != "ru-RU" || q.Get("tz") != "Europe/Moscow" || q.Get("t") != "пас,обмен,сдался,таймаут" {
|
||||||
|
t.Errorf("unexpected query %q", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"path":"/dl/g-1/png?e=1&s=x","filename":"game-g-1.png"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, _ := reg.Lookup(transcode.MsgGameExportURL)
|
||||||
|
|
||||||
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
gid := b.CreateString("g-1")
|
||||||
|
kind := b.CreateString("png")
|
||||||
|
dl := b.CreateString("ru-RU")
|
||||||
|
tz := b.CreateString("Europe/Moscow")
|
||||||
|
labels := make([]flatbuffers.UOffsetT, 0, 4)
|
||||||
|
for _, s := range []string{"пас", "обмен", "сдался", "таймаут"} {
|
||||||
|
labels = append(labels, b.CreateString(s))
|
||||||
|
}
|
||||||
|
fb.ExportUrlRequestStartActionLabelsVector(b, len(labels))
|
||||||
|
for i := len(labels) - 1; i >= 0; i-- {
|
||||||
|
b.PrependUOffsetT(labels[i])
|
||||||
|
}
|
||||||
|
vec := b.EndVector(len(labels))
|
||||||
|
fb.ExportUrlRequestStart(b)
|
||||||
|
fb.ExportUrlRequestAddGameId(b, gid)
|
||||||
|
fb.ExportUrlRequestAddKind(b, kind)
|
||||||
|
fb.ExportUrlRequestAddDateLocale(b, dl)
|
||||||
|
fb.ExportUrlRequestAddActionLabels(b, vec)
|
||||||
|
fb.ExportUrlRequestAddTimeZone(b, tz)
|
||||||
|
b.Finish(fb.ExportUrlRequestEnd(b))
|
||||||
|
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
res := fb.GetRootAsExportUrl(payload, 0)
|
||||||
|
if string(res.Path()) != "/dl/g-1/png?e=1&s=x" || string(res.Filename()) != "game-g-1.png" {
|
||||||
|
t.Fatalf("export url decoded wrong: path=%q filename=%q", res.Path(), res.Filename())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProfileUpdateRoundTripAway(t *testing.T) {
|
func TestProfileUpdateRoundTripAway(t *testing.T) {
|
||||||
var gotBody map[string]any
|
var gotBody map[string]any
|
||||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vkTestSecret = "wv527nm6mvf3rgomfhd6"
|
||||||
|
// vkGoldenSign is the base64url HMAC-SHA256 of the unsigned params below under
|
||||||
|
// vkTestSecret, computed independently (a Python reference) — so a green test
|
||||||
|
// exercises VK's real algorithm end to end, not a self-consistent fake.
|
||||||
|
vkGoldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vkParams is the canonical VK launch-parameter set (without the sign) that
|
||||||
|
// vkGoldenSign covers.
|
||||||
|
func vkParams() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"vk_access_token_settings": {""},
|
||||||
|
"vk_app_id": {"6736218"},
|
||||||
|
"vk_are_notifications_enabled": {"0"},
|
||||||
|
"vk_is_app_user": {"1"},
|
||||||
|
"vk_is_favorite": {"0"},
|
||||||
|
"vk_language": {"ru"},
|
||||||
|
"vk_platform": {"android"},
|
||||||
|
"vk_ref": {"other"},
|
||||||
|
"vk_ts": {"1546961916"},
|
||||||
|
"vk_user_id": {"494075"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vkLoginPayload(params, browserTz, displayName string) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(0)
|
||||||
|
p := b.CreateString(params)
|
||||||
|
tz := b.CreateString(browserTz)
|
||||||
|
dn := b.CreateString(displayName)
|
||||||
|
fb.VKLoginRequestStart(b)
|
||||||
|
fb.VKLoginRequestAddParams(b, p)
|
||||||
|
fb.VKLoginRequestAddBrowserTz(b, tz)
|
||||||
|
fb.VKLoginRequestAddDisplayName(b, dn)
|
||||||
|
b.Finish(fb.VKLoginRequestEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVKAuthForwardsSeedFields(t *testing.T) {
|
||||||
|
var gotBody map[string]string
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/internal/sessions/vk" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
_, _ = w.Write([]byte(`{"token":"tok-vk","user_id":"u-vk","is_guest":false,"display_name":"Иван"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||||
|
op, ok := reg.Lookup(transcode.MsgAuthVK)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("auth.vk not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
signed := vkParams()
|
||||||
|
signed.Set("sign", vkGoldenSign)
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(signed.Encode(), "+03:00", "Иван Петров")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
sess := fb.GetRootAsSession(payload, 0)
|
||||||
|
if string(sess.Token()) != "tok-vk" || string(sess.UserId()) != "u-vk" {
|
||||||
|
t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId())
|
||||||
|
}
|
||||||
|
// The verified vk_user_id and vk_language plus the client-supplied display name are
|
||||||
|
// forwarded so the backend can seed a brand-new account.
|
||||||
|
if gotBody["external_id"] != "494075" || gotBody["language_code"] != "ru" || gotBody["display_name"] != "Иван Петров" {
|
||||||
|
t.Errorf("forwarded body = %+v, want external_id=494075 language_code=ru display_name=Иван Петров", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVKAuthInvalidSign confirms a bad signature is a domain failure (invalid_vk_params)
|
||||||
|
// and the backend is never called.
|
||||||
|
func TestVKAuthInvalidSign(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Error("backend must not be called when the sign is invalid")
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||||
|
op, _ := reg.Lookup(transcode.MsgAuthVK)
|
||||||
|
|
||||||
|
tampered := vkParams()
|
||||||
|
tampered.Set("sign", "deadbeef")
|
||||||
|
_, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(tampered.Encode(), "", "")})
|
||||||
|
if code, ok := transcode.DomainCode(err); !ok || code != "invalid_vk_params" {
|
||||||
|
t.Errorf("DomainCode = (%q, %v), want (invalid_vk_params, true)", code, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVKAuthDisabledWithoutSecret confirms a blank VK app secret leaves auth.vk
|
||||||
|
// unregistered.
|
||||||
|
func TestVKAuthDisabledWithoutSecret(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
|
defer cleanup()
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
if _, ok := reg.Lookup(transcode.MsgAuthVK); ok {
|
||||||
|
t.Error("auth.vk should be unregistered without a VK app secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Package vkauth verifies VK Mini App launch parameters in-process. VK signs the
|
||||||
|
// launch query string with the app's protected ("secure") key; the gateway holds that
|
||||||
|
// secret (GATEWAY_VK_APP_SECRET) and validates the `sign` itself rather than calling a
|
||||||
|
// side-service, because the check is a pure offline HMAC with no VK API round-trip
|
||||||
|
// (unlike the Telegram validator, which lives in a separate process to isolate the bot
|
||||||
|
// token). See docs/ARCHITECTURE.md §12.
|
||||||
|
package vkauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalid is returned when launch params fail signature verification, are
|
||||||
|
// malformed, carry no signed vk_* parameters, or lack the vk_user_id.
|
||||||
|
var ErrInvalid = errors.New("vkauth: invalid vk launch params")
|
||||||
|
|
||||||
|
// Identity is the user extracted from verified VK launch params. ExternalID is the
|
||||||
|
// vk_user_id used as the identities external_id; Language is the vk_language hint that
|
||||||
|
// seeds a brand-new account's preferred language.
|
||||||
|
type Identity struct {
|
||||||
|
ExternalID string
|
||||||
|
Language string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
||||||
|
// returns the launching user's identity. Per VK's documented algorithm the signature
|
||||||
|
// is HMAC-SHA256 over the vk_*-prefixed parameters — sorted by key and serialized as a
|
||||||
|
// URL-encoded query string (url.Values.Encode mirrors VK's reference serialization for
|
||||||
|
// the constrained launch-parameter charset) — under the app secret, then base64url
|
||||||
|
// without padding; the comparison is constant-time.
|
||||||
|
//
|
||||||
|
// VK launch params carry no built-in expiry (unlike Telegram's auth_date), so freshness
|
||||||
|
// is deliberately not enforced here: the gateway mints its own short-lived session, and
|
||||||
|
// a replay only re-authenticates the same vk_user_id.
|
||||||
|
func Verify(params, secret string) (Identity, error) {
|
||||||
|
values, err := url.ParseQuery(params)
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
sign := values.Get("sign")
|
||||||
|
if sign == "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
// Only vk_*-prefixed parameters are signed; any other query parameter the client
|
||||||
|
// appended is outside the signature and must be excluded from the check.
|
||||||
|
signed := url.Values{}
|
||||||
|
for k, v := range values {
|
||||||
|
if strings.HasPrefix(k, "vk_") {
|
||||||
|
signed[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(signed) == 0 {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(signed.Encode()))
|
||||||
|
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
if subtle.ConstantTimeCompare([]byte(want), []byte(sign)) != 1 {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
externalID := values.Get("vk_user_id")
|
||||||
|
if externalID == "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package vkauth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/vkauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testSecret = "wv527nm6mvf3rgomfhd6"
|
||||||
|
// goldenSign is HMAC-SHA256(sorted vk_* query, testSecret) base64url without
|
||||||
|
// padding, computed independently from a Python reference over goldenParams — so a
|
||||||
|
// green test proves Verify matches VK's documented algorithm, not merely itself.
|
||||||
|
goldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goldenParams is the canonical VK launch-parameter set the golden signature covers
|
||||||
|
// (a representative real launch, including the empty vk_access_token_settings).
|
||||||
|
func goldenParams() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"vk_access_token_settings": {""},
|
||||||
|
"vk_app_id": {"6736218"},
|
||||||
|
"vk_are_notifications_enabled": {"0"},
|
||||||
|
"vk_is_app_user": {"1"},
|
||||||
|
"vk_is_favorite": {"0"},
|
||||||
|
"vk_language": {"ru"},
|
||||||
|
"vk_platform": {"android"},
|
||||||
|
"vk_ref": {"other"},
|
||||||
|
"vk_ts": {"1546961916"},
|
||||||
|
"vk_user_id": {"494075"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedParams() url.Values {
|
||||||
|
p := goldenParams()
|
||||||
|
p.Set("sign", goldenSign)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyValid(t *testing.T) {
|
||||||
|
id, err := vkauth.Verify(signedParams().Encode(), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "494075" || id.Language != "ru" {
|
||||||
|
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
||||||
|
// value: vk_access_token_settings can carry a comma (e.g. "email,phone"), which VK's
|
||||||
|
// signer and our url.Values.Encode both escape to %2C. The golden sign was computed
|
||||||
|
// independently (Node crypto) over these params under testSecret — so a green test
|
||||||
|
// proves Go's encoding matches VK's for that character.
|
||||||
|
func TestVerifyCommaValue(t *testing.T) {
|
||||||
|
p := url.Values{
|
||||||
|
"vk_access_token_settings": {"email,phone"},
|
||||||
|
"vk_app_id": {"6736218"},
|
||||||
|
"vk_language": {"ru"},
|
||||||
|
"vk_platform": {"mobile_web"},
|
||||||
|
"vk_ts": {"1546961916"},
|
||||||
|
"vk_user_id": {"494075"},
|
||||||
|
"sign": {"6g9FKCAfHfT-fBUdBAcJ5QK1xUm-vKMvGZrQ63aPgnQ"},
|
||||||
|
}
|
||||||
|
id, err := vkauth.Verify(p.Encode(), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "494075" {
|
||||||
|
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyIgnoresForeignParams asserts a non-vk_ query parameter the client may
|
||||||
|
// append (e.g. a tracking tag) is outside the signed set and does not break the check.
|
||||||
|
func TestVerifyIgnoresForeignParams(t *testing.T) {
|
||||||
|
id, err := vkauth.Verify(signedParams().Encode()+"&utm_source=catalog", testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "494075" {
|
||||||
|
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejects(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
secret string
|
||||||
|
}{
|
||||||
|
{name: "tampered param", secret: testSecret, raw: func() string {
|
||||||
|
p := signedParams()
|
||||||
|
p.Set("vk_user_id", "1") // user id changed; the golden sign no longer matches
|
||||||
|
return p.Encode()
|
||||||
|
}()},
|
||||||
|
{name: "wrong secret", secret: "not-the-secret", raw: signedParams().Encode()},
|
||||||
|
{name: "missing sign", secret: testSecret, raw: goldenParams().Encode()},
|
||||||
|
{name: "no vk params", secret: testSecret, raw: url.Values{"sign": {goldenSign}, "foo": {"bar"}}.Encode()},
|
||||||
|
{name: "malformed query", secret: testSecret, raw: "%zz=bad"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if _, err := vkauth.Verify(tt.raw, tt.secret); !errors.Is(err, vkauth.ErrInvalid) {
|
||||||
|
t.Fatalf("Verify error = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,8 @@ github.com/volatiletech/randomize v0.0.1 h1:eE5yajattWqTB2/eN8df4dw+8jwAzBtbdo5s
|
|||||||
github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY=
|
github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY=
|
||||||
github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
|
github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
|
||||||
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
|
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
|
||||||
|
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
|
||||||
|
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
@@ -176,15 +178,18 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA
|
|||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
|
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
|
||||||
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
|
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
|
||||||
|
|||||||
@@ -108,6 +108,20 @@ table TelegramLoginRequest {
|
|||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
|
||||||
|
// signed vk_* launch parameters plus the sign, which the gateway verifies in-process
|
||||||
|
// (HMAC-SHA256 over the sorted vk_* params under the app secret, base64url) before
|
||||||
|
// forwarding the extracted vk_user_id to the backend. display_name is the player's
|
||||||
|
// name read client-side via VKWebAppGetUserInfo — VK omits it from the signed params,
|
||||||
|
// so it is an untrusted, cosmetic seed for a brand-new account's display name.
|
||||||
|
// browser_tz is the client's detected UTC offset ("±HH:MM"), seeded into a brand-new
|
||||||
|
// account's time zone (see TelegramLoginRequest.browser_tz; first contact only).
|
||||||
|
table VKLoginRequest {
|
||||||
|
params:string;
|
||||||
|
browser_tz:string;
|
||||||
|
display_name:string;
|
||||||
|
}
|
||||||
|
|
||||||
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
|
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
|
||||||
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
|
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
|
||||||
// guest account's time zone (see TelegramLoginRequest.browser_tz).
|
// guest account's time zone (see TelegramLoginRequest.browser_tz).
|
||||||
@@ -644,6 +658,27 @@ table GcgExport {
|
|||||||
content:string;
|
content:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportUrlRequest asks for a signed download URL of a finished game's export artifact.
|
||||||
|
// kind is "png" or "gcg". For the PNG the client passes its presentation context — the
|
||||||
|
// UI-localized non-play move labels (pass, exchange, resign, timeout, in that order),
|
||||||
|
// the device date locale and the device IANA time zone — which ride the signed URL so
|
||||||
|
// the server render matches what the player would have seen locally.
|
||||||
|
table ExportUrlRequest {
|
||||||
|
game_id:string;
|
||||||
|
kind:string;
|
||||||
|
date_locale:string;
|
||||||
|
action_labels:[string];
|
||||||
|
time_zone:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportUrl is the minted download link: a relative, signed, short-lived path the client
|
||||||
|
// resolves against its own origin (the SPA and the gateway share it), plus the filename
|
||||||
|
// the download will carry.
|
||||||
|
table ExportUrl {
|
||||||
|
path:string;
|
||||||
|
filename:string;
|
||||||
|
}
|
||||||
|
|
||||||
// --- push event payloads ---
|
// --- push event payloads ---
|
||||||
|
|
||||||
// YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the
|
// YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExportUrl struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsExportUrl(buf []byte, offset flatbuffers.UOffsetT) *ExportUrl {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &ExportUrl{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishExportUrlBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsExportUrl(buf []byte, offset flatbuffers.UOffsetT) *ExportUrl {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &ExportUrl{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedExportUrlBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrl) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrl) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrl) Path() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrl) Filename() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportUrlStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(2)
|
||||||
|
}
|
||||||
|
func ExportUrlAddPath(builder *flatbuffers.Builder, path flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(path), 0)
|
||||||
|
}
|
||||||
|
func ExportUrlAddFilename(builder *flatbuffers.Builder, filename flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(filename), 0)
|
||||||
|
}
|
||||||
|
func ExportUrlEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExportUrlRequest struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsExportUrlRequest(buf []byte, offset flatbuffers.UOffsetT) *ExportUrlRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &ExportUrlRequest{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishExportUrlRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsExportUrlRequest(buf []byte, offset flatbuffers.UOffsetT) *ExportUrlRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &ExportUrlRequest{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedExportUrlRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrlRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrlRequest) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrlRequest) GameId() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrlRequest) Kind() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrlRequest) DateLocale() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrlRequest) ActionLabels(j int) []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
|
if o != 0 {
|
||||||
|
a := rcv._tab.Vector(o)
|
||||||
|
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrlRequest) ActionLabelsLength() int {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.VectorLen(o)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *ExportUrlRequest) TimeZone() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportUrlRequestStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(5)
|
||||||
|
}
|
||||||
|
func ExportUrlRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||||
|
}
|
||||||
|
func ExportUrlRequestAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(kind), 0)
|
||||||
|
}
|
||||||
|
func ExportUrlRequestAddDateLocale(builder *flatbuffers.Builder, dateLocale flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(dateLocale), 0)
|
||||||
|
}
|
||||||
|
func ExportUrlRequestAddActionLabels(builder *flatbuffers.Builder, actionLabels flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actionLabels), 0)
|
||||||
|
}
|
||||||
|
func ExportUrlRequestStartActionLabelsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
|
return builder.StartVector(4, numElems, 4)
|
||||||
|
}
|
||||||
|
func ExportUrlRequestAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(timeZone), 0)
|
||||||
|
}
|
||||||
|
func ExportUrlRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VKLoginRequest struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &VKLoginRequest{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &VKLoginRequest{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) Params() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) BrowserTz() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) DisplayName() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func VKLoginRequestStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(3)
|
||||||
|
}
|
||||||
|
func VKLoginRequestAddParams(builder *flatbuffers.Builder, params flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(params), 0)
|
||||||
|
}
|
||||||
|
func VKLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||||
|
}
|
||||||
|
func VKLoginRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(displayName), 0)
|
||||||
|
}
|
||||||
|
func VKLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
||||||
// the hash, is malformed, or is older than the freshness window.
|
// the hash, is malformed, is older than the freshness window, or identifies a bot
|
||||||
|
// user (is_bot), which is denied.
|
||||||
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
||||||
|
|
||||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||||
@@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) {
|
|||||||
}
|
}
|
||||||
var u struct {
|
var u struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
IsBot bool `json:"is_bot"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
@@ -127,6 +129,12 @@ func parseUser(userJSON string) (User, error) {
|
|||||||
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
||||||
return User{}, ErrInvalidInitData
|
return User{}, ErrInvalidInitData
|
||||||
}
|
}
|
||||||
|
// Deny bot principals: the HMAC has already proved Telegram signed this payload, so is_bot==true
|
||||||
|
// is Telegram itself attesting the launching user is a bot. A real user opening the Mini App
|
||||||
|
// never carries it, so reject defensively rather than provision an account for a bot.
|
||||||
|
if u.IsBot {
|
||||||
|
return User{}, ErrInvalidInitData
|
||||||
|
}
|
||||||
return User{
|
return User{
|
||||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
|
|||||||
@@ -83,3 +83,28 @@ func TestValidateRejects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateBotUser(t *testing.T) {
|
||||||
|
t.Run("is_bot true is denied", func(t *testing.T) {
|
||||||
|
initData := signInitData(testToken, map[string]string{
|
||||||
|
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||||
|
"user": `{"id":42,"is_bot":true,"first_name":"Robo"}`,
|
||||||
|
})
|
||||||
|
if _, err := NewHMACValidator(testToken).Validate(initData); !errors.Is(err, ErrInvalidInitData) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("is_bot false is allowed", func(t *testing.T) {
|
||||||
|
initData := signInitData(testToken, map[string]string{
|
||||||
|
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||||
|
"user": `{"id":42,"is_bot":false,"first_name":"Thomas"}`,
|
||||||
|
})
|
||||||
|
u, err := NewHMACValidator(testToken).Validate(initData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate: %v", err)
|
||||||
|
}
|
||||||
|
if u.ExternalID != "42" || u.FirstName != "Thomas" {
|
||||||
|
t.Errorf("user = %+v, want {42 Thomas}", u)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# renderer — the finished-game image-render sidecar: Node + skia-canvas running the SAME
|
||||||
|
# ui/src/lib/gameimage.ts the web client unit-tests, bundled at build time (renderer/README.md).
|
||||||
|
# Debian slim, not the repo's usual alpine/distroless: skia-canvas ships prebuilt glibc
|
||||||
|
# binaries and resolves fonts through fontconfig, and the runtime fonts are baked into the
|
||||||
|
# image — Liberation Sans (the UI font stack's Linux face) + Noto Color Emoji (the 🏆).
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
WORKDIR /src/renderer
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
|
||||||
|
COPY renderer/package.json renderer/pnpm-lock.yaml renderer/pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY renderer/build.mjs ./
|
||||||
|
COPY renderer/src ./src
|
||||||
|
COPY ui/src/lib /src/ui/src/lib
|
||||||
|
RUN pnpm run bundle
|
||||||
|
|
||||||
|
FROM node:22-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends fonts-liberation fonts-noto-color-emoji \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ARG VERSION=dev
|
||||||
|
ENV APP_VERSION=${VERSION}
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /src/renderer/node_modules ./node_modules
|
||||||
|
COPY --from=build /src/renderer/dist ./dist
|
||||||
|
COPY renderer/src/server.mjs renderer/src/render.mjs ./src/
|
||||||
|
USER node
|
||||||
|
EXPOSE 8090
|
||||||
|
CMD ["node", "src/server.mjs"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# renderer — the finished-game image-render sidecar
|
||||||
|
|
||||||
|
An internal-only Node service that rasterizes the finished-game export PNG. It runs the
|
||||||
|
**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image
|
||||||
|
build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on
|
||||||
|
[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is
|
||||||
|
pixel-identical to the design the owner signed off in the browser.
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
- `POST /render` — `{game, moves, alphabet, labels, dateLocale, hostname, scale?}` →
|
||||||
|
`image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`);
|
||||||
|
`alphabet` is the per-variant `(index, letter, value)` table tile values are drawn
|
||||||
|
from; `labels` localizes the non-play moves (pass/exchange/resign/timeout);
|
||||||
|
`hostname` + `dateLocale` feed the footer.
|
||||||
|
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
|
||||||
|
|
||||||
|
The service draws and nothing else: authentication, the participant check and the signed
|
||||||
|
public download URL all live in the backend (`backend/internal/server/export.go`); the
|
||||||
|
network path is client → gateway `/dl/*` → backend → this sidecar.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
|
||||||
|
# (allowBuilds) or the native binary is silently never fetched
|
||||||
|
pnpm test # bundles, then node --test against testdata/request.json
|
||||||
|
node src/server.mjs # local run on :8090 (RENDERER_PORT overrides)
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
|
||||||
|
UI font stack's Linux face) and Noto Color Emoji (the scoresheet 🏆); skia-canvas picks
|
||||||
|
both up through fontconfig. The fixture `testdata/request.json` is a real 35-move
|
||||||
|
self-played Russian Scrabble game.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Bundles the shared ui/src/lib game-image renderer (see src/entry.ts) into
|
||||||
|
// dist/gameimage.mjs for the sidecar runtime. Uses the esbuild JS API — the CLI shim is
|
||||||
|
// unreliable under pnpm's bin wrapper (it re-runs the native binary through node).
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: ['src/entry.ts'],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'node',
|
||||||
|
outfile: 'dist/gameimage.mjs',
|
||||||
|
logLevel: 'info',
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "scrabble-renderer",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Internal sidecar that renders the finished-game export PNG with the same ui/src/lib/gameimage.ts the browser build tests, on skia-canvas.",
|
||||||
|
"scripts": {
|
||||||
|
"bundle": "node build.mjs",
|
||||||
|
"test": "pnpm run bundle && node --test test/*.test.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"skia-canvas": "^3.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"skia-canvas"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+366
@@ -0,0 +1,366 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
skia-canvas:
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.8
|
||||||
|
devDependencies:
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.25.0
|
||||||
|
version: 0.25.12
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.25.12':
|
||||||
|
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.25.12':
|
||||||
|
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.25.12':
|
||||||
|
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.25.12':
|
||||||
|
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.25.12':
|
||||||
|
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.25.12':
|
||||||
|
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.25.12':
|
||||||
|
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
agent-base@7.1.4:
|
||||||
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
esbuild@0.25.12:
|
||||||
|
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
follow-redirects@1.16.0:
|
||||||
|
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
ms@2.1.3:
|
||||||
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
parenthesis@3.1.8:
|
||||||
|
resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==}
|
||||||
|
|
||||||
|
skia-canvas@3.0.8:
|
||||||
|
resolution: {integrity: sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==}
|
||||||
|
|
||||||
|
string-split-by@1.0.0:
|
||||||
|
resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.25.12':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
esbuild@0.25.12:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.25.12
|
||||||
|
'@esbuild/android-arm': 0.25.12
|
||||||
|
'@esbuild/android-arm64': 0.25.12
|
||||||
|
'@esbuild/android-x64': 0.25.12
|
||||||
|
'@esbuild/darwin-arm64': 0.25.12
|
||||||
|
'@esbuild/darwin-x64': 0.25.12
|
||||||
|
'@esbuild/freebsd-arm64': 0.25.12
|
||||||
|
'@esbuild/freebsd-x64': 0.25.12
|
||||||
|
'@esbuild/linux-arm': 0.25.12
|
||||||
|
'@esbuild/linux-arm64': 0.25.12
|
||||||
|
'@esbuild/linux-ia32': 0.25.12
|
||||||
|
'@esbuild/linux-loong64': 0.25.12
|
||||||
|
'@esbuild/linux-mips64el': 0.25.12
|
||||||
|
'@esbuild/linux-ppc64': 0.25.12
|
||||||
|
'@esbuild/linux-riscv64': 0.25.12
|
||||||
|
'@esbuild/linux-s390x': 0.25.12
|
||||||
|
'@esbuild/linux-x64': 0.25.12
|
||||||
|
'@esbuild/netbsd-arm64': 0.25.12
|
||||||
|
'@esbuild/netbsd-x64': 0.25.12
|
||||||
|
'@esbuild/openbsd-arm64': 0.25.12
|
||||||
|
'@esbuild/openbsd-x64': 0.25.12
|
||||||
|
'@esbuild/openharmony-arm64': 0.25.12
|
||||||
|
'@esbuild/sunos-x64': 0.25.12
|
||||||
|
'@esbuild/win32-arm64': 0.25.12
|
||||||
|
'@esbuild/win32-ia32': 0.25.12
|
||||||
|
'@esbuild/win32-x64': 0.25.12
|
||||||
|
|
||||||
|
follow-redirects@1.16.0: {}
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
agent-base: 7.1.4
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
parenthesis@3.1.8: {}
|
||||||
|
|
||||||
|
skia-canvas@3.0.8:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
follow-redirects: 1.16.0
|
||||||
|
https-proxy-agent: 7.0.6
|
||||||
|
string-split-by: 1.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
string-split-by@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
parenthesis: 3.1.8
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# pnpm 11 records build-script approval here. esbuild's postinstall materialises its CLI
|
||||||
|
# shim; skia-canvas's postinstall downloads the prebuilt native .node binary — without the
|
||||||
|
# approval it is silently skipped and the service fails at boot.
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
skia-canvas: true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib —
|
||||||
|
// the exact module the browser build unit-tests — plus the alphabet cache seeder the
|
||||||
|
// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle`
|
||||||
|
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source).
|
||||||
|
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
|
||||||
|
export { setAlphabet } from '../../ui/src/lib/alphabet';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// The render core, separated from the HTTP wiring so the node test can call it directly.
|
||||||
|
import { Canvas } from 'skia-canvas';
|
||||||
|
import { drawGameImage, setAlphabet } from '../dist/gameimage.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderRequest rasterizes one export request — the JSON shape the backend sends:
|
||||||
|
* { game, moves, alphabet, labels, dateLocale, hostname, scale? } — into a PNG buffer.
|
||||||
|
* game/moves are the ui-model shapes (GameView / MoveRecord[]) the shared drawGameImage
|
||||||
|
* consumes; alphabet is the per-variant (index, letter, value) table it scores tiles with.
|
||||||
|
*/
|
||||||
|
export async function renderRequest(payload) {
|
||||||
|
const { game, moves, alphabet, labels, dateLocale, timeZone, hostname, scale } = payload;
|
||||||
|
if (!game || !Array.isArray(moves)) throw Object.assign(new Error('bad payload'), { status: 400 });
|
||||||
|
setAlphabet(game.variant, alphabet ?? []);
|
||||||
|
const canvas = new Canvas(1, 1);
|
||||||
|
drawGameImage(canvas, game, moves, {
|
||||||
|
actionLabel: (a) => (labels && labels[a]) || a,
|
||||||
|
hostname: hostname || '',
|
||||||
|
dateLocale: dateLocale || undefined,
|
||||||
|
timeZone: timeZone || undefined,
|
||||||
|
scale: scale || 2,
|
||||||
|
});
|
||||||
|
return canvas.toBuffer('png');
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the
|
||||||
|
// finished-game export image. It runs the same drawGameImage the browser build ships
|
||||||
|
// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from
|
||||||
|
// the image (Liberation Sans + Noto Color Emoji via fontconfig).
|
||||||
|
//
|
||||||
|
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
|
||||||
|
// GET /healthz → 200
|
||||||
|
//
|
||||||
|
// The service is internal-only (docker network `internal`); authentication, participant
|
||||||
|
// checks and the signed public URL all live in the backend — this process only draws.
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { renderRequest } from './render.mjs';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.RENDERER_PORT || 8090);
|
||||||
|
// A render request is a finished game's journal — generously capped.
|
||||||
|
const MAX_BODY = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (c) => {
|
||||||
|
size += c.length;
|
||||||
|
if (size > MAX_BODY) {
|
||||||
|
reject(Object.assign(new Error('body too large'), { status: 413 }));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(c);
|
||||||
|
});
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET' && req.url === '/healthz') {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/render') {
|
||||||
|
const png = await renderRequest(JSON.parse(await readBody(req)));
|
||||||
|
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404).end();
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || (err instanceof SyntaxError ? 400 : 500);
|
||||||
|
console.error(`render error: ${err.message}`);
|
||||||
|
res.writeHead(status, { 'content-type': 'text/plain' }).end('render failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`renderer listening on :${PORT}`));
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Smoke test for the render core: feeds the committed fixture (a real 35-move self-played
|
||||||
|
// Russian Scrabble game) through renderRequest and asserts a sane PNG comes back. Pixel
|
||||||
|
// goldens are deliberately avoided — font rasterization differs across hosts; the shared
|
||||||
|
// drawing logic itself is unit-tested in ui/ (gameimage.test.ts).
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { renderRequest } from '../src/render.mjs';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const fixture = () => JSON.parse(readFileSync(join(here, '../testdata/request.json'), 'utf8'));
|
||||||
|
|
||||||
|
function pngSize(buf) {
|
||||||
|
// IHDR is the first chunk: width/height are big-endian u32 at offsets 16/20.
|
||||||
|
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders the fixture game to a plausible PNG', async () => {
|
||||||
|
const png = await renderRequest(fixture());
|
||||||
|
assert.ok(png.length > 50_000, `png too small: ${png.length}`);
|
||||||
|
assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const { w, h } = pngSize(png);
|
||||||
|
// scale 2 over a MIN_SIDE 620 board + two seat columns: sanity bounds, not a golden.
|
||||||
|
assert.ok(w > 1600 && w < 3000, `unexpected width ${w}`);
|
||||||
|
assert.ok(h > 1000 && h < 3000, `unexpected height ${h}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a payload without a game', async () => {
|
||||||
|
await assert.rejects(() => renderRequest({ moves: [] }), /bad payload/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders concurrently without cross-talk', async () => {
|
||||||
|
const [a, b] = await Promise.all([renderRequest(fixture()), renderRequest(fixture())]);
|
||||||
|
assert.equal(a.length, b.length);
|
||||||
|
});
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user