Compare commits

..

5 Commits

Author SHA1 Message Date
Ilia Denisov 38644f2a59 perf(ui): cancel the in-flight move preview when tiles move; debug cache-bust
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Placing or recalling a tile fires the network preview; on a slow link the requests
piled up and an out-of-order response could overwrite the newest with a stale score.
Cancel the previous in-flight evaluate on each change — evaluate is non-mutating, so
aborting is safe (only submit_play and the debounced draft save persist), so only the
latest request runs and the newest result wins; the game also aborts it on unmount.
The abort threads an AbortSignal through the Connect exec (an intentional cancel does
not retry). The debug reset now also bypasses the browser HTTP cache on the next
dictionary load, so a "clear" forces a genuinely cold test — clearing IndexedDB alone
left the immutable blob served straight from the HTTP cache.
2026-07-01 22:42:41 +02:00
Ilia Denisov 6a6fdffd7f fix(ui): abort a stalled dictionary download; trip the breaker on warm timeouts
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
On a slow link (EDGE) a dictionary download kept running in the background after the
warm-up gave up or the game was left, starving the channel — a new vs-AI game would
not open, and the breaker (which only counted fetch errors) never tripped, so the
warm-up overlay kept reappearing instead of settling to the network after a few
tries. Now:
- the warm-up passes an AbortSignal and aborts the download at its 5s cap, and the
  game aborts it on unmount (leaving the game), so a large download stops holding the
  channel — a subsequent action (creating a game) gets the bandwidth.
- the breaker counts warm-up misses (a load that failed OR hit the cap), not just
  fetch errors, so a persistently slow link trips it after 3 and the preview then
  goes straight to the network.
2026-07-01 22:21:08 +02:00
Ilia Denisov d57dbbab4f perf(ui): deprioritise the dictionary download; drop the lobby prefetch
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
On a slow link (EDGE) the background dictionary download starved the game's own
traffic — the board and the move preview stalled behind a 450KB fetch. Fetch the
dawg at low priority (Priority Hints; ignored on iOS WebKit, harmless) so the
browser schedules it behind the game's requests, and load it only on game open —
the lobby no longer prefetches, which was its main competitor for the channel.
Evict a cached blob the reader rejects (a stale or partial entry) so it self-heals
instead of lingering and re-fetches. Raise the app-entry bundle budget to 110KB
(the feature's small in-entry game/debug wiring; the heavy dict code stays lazy).
2026-07-01 21:31:23 +02:00
Ilia Denisov 2776ef83c2 fix(deploy): route /dict through caddy to the gateway (local eval)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
The edge caddy @gateway matcher was missing /dict/*, so the client's dictionary
fetch fell to the static landing catch-all: it received a non-dawg blob (200 OK),
which the reader parsed into a bogus finder that reports every word missing — a
valid first move showed as illegal with no network fallback. Add /dict/* to the
gateway route.

Hardening + the cross-test that would have caught it:
- reader: reject a blob whose 32-bit size header != its byte length, so a
  non-dawg page or a truncated download now throws and the loader falls back to
  the network preview instead of silently validating against garbage.
- eval.parity: an adapter conformance suite that drives evaluateLocal through the
  real letter-space path (the server alphabet table, a letter board and letter
  placements) against the engine — the algorithm-level validate.parity did not
  exercise the adapter. validategen now emits the alphabet table for it.
- a CI deploy probe asserts {edge}/dict reaches the gateway (401), not the landing.
- the DebugPanel reports the feature flag, the bad-connection breaker and the
  cached dictionaries with their sizes; its reset also clears the dict cache.
2026-07-01 20:55:20 +02:00
Ilia Denisov d24a127406 feat(ui): on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Score and validate a tentative move on-device instead of a per-arrangement
network round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a new `conformance` CI job. The server stays
authoritative — submit_play re-validates — so the local result is an advisory
accelerator only; any cache miss, storage eviction or a bad-connection breaker
falls back to the network evaluate.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: an IndexedDB blob cache (best-effort, storage.persist()) + a loader
  (memory -> IndexedDB -> network, session-scoped bad-connection breaker) + a lobby
  prefetch (your-turn first); an adapter over the existing premiums/alphabet; a
  DictWarmup overlay while a cold dictionary loads (120ms flash-guard, 5s cap ->
  network); a ?nolocal flag; the DebugPanel reset also clears the dict cache.
- parity: generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run
  in CI against the release dictionaries.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
2026-07-01 20:13:01 +02:00
234 changed files with 698 additions and 14070 deletions
+15 -99
View File
@@ -26,12 +26,12 @@ on:
push:
branches: [development]
# The dictionary release. One Gitea variable is the single source of truth: the
# test suite validates against it here (inherited by the unit/integration jobs) and
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
# is one edit (the variable). See deploy/README.md.
# The dictionary release the test suite validates against — the current
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
# 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.
env:
DICT_VERSION: ${{ vars.DICT_VERSION }}
DICT_VERSION: v1.3.1
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
@@ -73,9 +73,6 @@ jobs:
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 '^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.
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
else
@@ -202,14 +199,6 @@ jobs:
- name: Bundle-size budget
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
run: pnpm exec playwright install chromium webkit
timeout-minutes: 5
@@ -329,42 +318,11 @@ jobs:
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.
# One VK Mini App serves every contour -> unprefixed secret.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
# 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: one account for every
# contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend
# on the log mailer (email disabled) but the contour still boots.
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
# host:port. Empty leaves the alert worker off and Grafana SMTP disabled.
SMTP_RELAY_ADMIN_FROM: ${{ vars.TEST_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
# 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 }}
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
# TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived
# from PUBLIC_BASE_URL in the run step below, not stored as their own variables.
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
@@ -377,16 +335,11 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
# contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID);
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
# Unset vars render empty -> the compose ":-" defaults apply.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
run: |
# Seed the config files to a stable host path. The runner checks out into
@@ -397,34 +350,8 @@ jobs:
conf="$HOME/.scrabble-deploy"
rm -rf "$conf"
mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
cp -r caddy otelcol prometheus tempo grafana "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
# overlay is exercised on the test contour too (not only prod). Raised just before
# the recreate and lowered once caddy is back (below); the trap clears it if the
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
maint_flag="$conf/caddy/on"
trap 'rm -f "$maint_flag"' EXIT
# Derive the public URLs from the one canonical origin instead of storing each as
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
# Exported before build so the VK ID redirect is baked into the SPA.
base="${PUBLIC_BASE_URL%/}"
export TELEGRAM_MINIAPP_URL="$base/telegram/"
export GRAFANA_ROOT_URL="$base/_gm/grafana/"
export VITE_VK_ID_REDIRECT_URL="$base/app/"
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
# form the backend go-mail accepts) and validates it even when SMTP is disabled — a
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
# address + name for Grafana; the backend keeps the full form.
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
case "$svc_from" in
*"<"*">"*)
export GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
export GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
*)
export GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
esac
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
@@ -437,23 +364,12 @@ jobs:
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
# main host omits both. Without the profile they would not start here.
docker compose --ansi never --profile telegram-local build --progress plain
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
: > "$maint_flag"
docker compose --ansi never up -d --force-recreate --no-deps caddy
docker compose --ansi never --profile telegram-local up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
# Lower the maintenance page: services are back. An open SPA's poll now gets through
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
rm -f "$maint_flag"
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
- name: Probe the landing, gateway and backend
run: |
+44 -59
View File
@@ -40,22 +40,11 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
# VK Mini App link + VK ID "Web" app id: one value each serves every contour.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
# runtime var must be present at build even though it is not a build-arg — incl. the
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
# Mini App URL; both are computed in the build step, not stored variables.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
steps:
- uses: actions/checkout@v4
with:
@@ -69,15 +58,10 @@ jobs:
working-directory: deploy
run: |
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
# Derive the public URLs from the one canonical origin: the VK ID redirect is a
# build-arg baked into the SPA, and the Mini App URL satisfies the (profiled-out)
# bot service's compose ":?" guard during parse. See deploy/write-prod-env.sh.
base="${PUBLIC_BASE_URL%/}"
export VITE_VK_ID_REDIRECT_URL="$base/app/" TELEGRAM_MINIAPP_URL="$base/telegram/"
# The main-stack images via compose (reuses the build args, incl. VERSION);
# The four main-stack images via compose (reuses the build args, incl. VERSION);
# 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 push backend gateway landing validator renderer
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator
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"
@@ -98,45 +82,18 @@ jobs:
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# 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): one account for
# every contour -> unprefixed host/port/tls/user/pass.
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails + Grafana infra alerts (distinct senders +
# recipients; Grafana uses the relay's STARTTLS host:port).
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
# deploy/write-prod-env.sh, not stored variables.
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
steps:
- uses: actions/checkout@v4
with:
@@ -164,8 +121,25 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-main
# Shared with prod-rollback so the two paths render an identical runtime env.
APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TAG'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
@@ -176,7 +150,7 @@ jobs:
ssh_main 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
tar -C deploy -czf - caddy otelcol prometheus tempo grafana blackbox \
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
| ssh_main 'tar -C /opt/scrabble -xzf -'
tar -C stage -czf - certs-main \
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
@@ -204,8 +178,7 @@ jobs:
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
# PUBLIC_BASE_URL drives the derived Mini App URL in deploy/write-prod-bot-env.sh.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
@@ -222,8 +195,20 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-bot
# Shared with prod-rollback so the two paths render an identical bot env.
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
+35 -30
View File
@@ -51,32 +51,13 @@ jobs:
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
# Full runtime env — parity with prod-deploy's deploy-main so a rollback re-renders
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
INPUT_TARGET: ${{ inputs.target_version }}
steps:
- uses: actions/checkout@v4
@@ -108,9 +89,24 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-main
# Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
# runtime env (not a subset), so email / VK login / Grafana alerts survive it.
APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TARGET'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
@@ -151,11 +147,9 @@ jobs:
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
# PUBLIC_BASE_URL drives the derived Mini App URL; SUPPORT_CHAT_ID for parity with deploy.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
@@ -169,8 +163,19 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-bot
# Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
+1 -3
View File
@@ -125,10 +125,9 @@ gateway/ # module scrabble/gateway: Connect-RPC edge, embeds
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
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)
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
docs/ .gitea/workflows/ CLAUDE.md README.md
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
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
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
```
@@ -144,7 +143,6 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
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 -f gateway/Dockerfile --target gateway -t scrabble-gateway .
-41
View File
@@ -1,41 +0,0 @@
# 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
```
-78
View File
@@ -1,78 +0,0 @@
'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(''));
-128
View File
@@ -1,128 +0,0 @@
'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
+5 -7
View File
@@ -214,13 +214,11 @@ 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_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_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). |
| `BACKEND_SMTP_PORT` | `587` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
| `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_USERNAME` | — | SMTP AUTH user; empty relays without authentication. |
| `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_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
| `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_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
+1 -41
View File
@@ -12,7 +12,6 @@ import (
"fmt"
"log"
"os/signal"
"strings"
"syscall"
"time"
@@ -21,7 +20,6 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/adminalert"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/banview"
"scrabble/backend/internal/config"
@@ -35,7 +33,6 @@ import (
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/pushgrpc"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
@@ -46,10 +43,6 @@ import (
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
// adminAlertInterval is how often the operator-alert worker checks for new feedback /
// complaints; a burst within one interval coalesces into a single digest email.
const adminAlertInterval = 5 * time.Minute
func main() {
cfg, err := config.Load()
if err != nil {
@@ -168,15 +161,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention))
// Purge the account-deletion legal dossier past its retention TTL: the
// retained-identities journal, and the feedback thread + dossier PII of long-deleted
// accounts (chat is kept). Checked daily; the TTL is a two-year policy constant.
retentionReaper := account.NewRetentionReaper(accounts, account.RetentionTTL, logger)
go retentionReaper.Run(ctx, 24*time.Hour)
logger.Info("retention reaper started",
zap.Duration("interval", 24*time.Hour),
zap.Duration("retention", account.RetentionTTL))
// Re-evaluate moderated-chat write access when a temporary block self-expires:
// no operator action fires then, so the sweeper emits the chat-access-changed
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
@@ -189,10 +173,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// 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.
mailer := newMailer(cfg.SMTP, logger)
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))
emails := account.NewEmailService(accounts, mailer)
// Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
@@ -213,18 +194,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub)
// Operator alert emails on new feedback / word complaints, coalesced into one digest
// per interval. Inert unless a distinct admin sender and recipient are configured.
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
consoleURL := ""
if cfg.PublicBaseURL != "" {
consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
}
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger)
go alerts.Run(ctx, adminAlertInterval)
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
}
// Robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker
// substitutes a pooled robot for a missing human after the wait window.
@@ -263,13 +232,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// block and the banner admin console section.
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{
Logger: logger,
DB: db,
@@ -291,8 +253,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
BanView: banView,
Ads: adsSvc,
Notifier: hub,
ExportSignKey: cfg.ExportSignKey,
Renderer: renderer,
})
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
+1 -2
View File
@@ -13,7 +13,6 @@ require (
github.com/pressly/goose/v3 v3.27.1
github.com/testcontainers/testcontainers-go 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/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
@@ -110,7 +109,7 @@ require (
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
-4
View File
@@ -279,8 +279,6 @@ 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/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -402,8 +400,6 @@ 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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+9 -45
View File
@@ -121,44 +121,13 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
}
// ProvisionEmail returns the account owning the email identity externalID, creating
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
// seeded into its time zone, language seeded from the client's UI language, and its
// display name seeded from the email's local part (so it is not left nameless). Like
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
// returning user's saved zone, language and name are never overwritten. The email account is
// created here (the code-request step), not at the later login, so this is where its
// zone and language are seeded. It is created flagged is_guest with an unconfirmed
// 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{
displayName: emailDisplayName(externalID),
timeZone: seedZone(browserTZ),
preferredLanguage: supportedLanguage(language),
isGuest: true,
})
}
// emailDisplayName derives a display name from an email address — the local part
// before '@', trimmed and capped to the column width — so a new email account is not
// left nameless. It is only the first-contact seed; the user can rename it later.
func emailDisplayName(email string) string {
local, _, _ := strings.Cut(email, "@")
local = strings.TrimSpace(local)
if r := []rune(local); len(r) > maxDisplayName {
local = strings.TrimRight(string(r[:maxDisplayName]), " ")
}
return local
}
// 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 ""
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
// and leaves an existing account untouched, so a returning user's saved zone is never
// overwritten. The email account is created here (the code-request step), not at the
// later login, so this is where its zone is seeded.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
}
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
@@ -270,11 +239,6 @@ type provisionSeed struct {
preferredLanguage string
displayName 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
@@ -483,8 +447,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
tz = "UTC"
}
insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest).
VALUES(accountID, seed.displayName, lang, tz, seed.isGuest).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
VALUES(accountID, seed.displayName, lang, tz).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
+37 -359
View File
@@ -5,7 +5,6 @@ import (
crand "crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
@@ -27,22 +26,6 @@ const (
emailCodeTTL = 15 * time.Minute
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
emailCodeMaxAttempts = 5
// linkTokenBytes is the entropy of a confirm deeplink token: 256 bits.
linkTokenBytes = 32
// emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token
// is appended). The SPA is served under /app/ behind a hash router.
emailConfirmPath = "/app/#/confirm/"
)
// Confirmation purposes recorded on a pending confirm-code row. They select what
// verifying the code or the deeplink token does: sign in (login), link/confirm the
// address on the current account (link), or replace the account's confirmed email with
// a new address (change). Account deletion adds a further purpose in a later stage.
const (
purposeLogin = "login"
purposeLink = "link"
purposeChange = "change"
purposeDelete = "delete"
)
// Errors returned by the email confirm-code flow.
@@ -63,12 +46,6 @@ var (
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
// ErrCodeMismatch is returned when the submitted 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")
// ErrNoEmail is returned when an email-code step-up is requested for an account that
// holds no confirmed email (the caller must use the typed-phrase path instead).
ErrNoEmail = errors.New("account: no confirmed email")
)
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
@@ -78,80 +55,14 @@ var (
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// and using an email as a login reuses this mechanism.
type EmailService struct {
store *Store
mailer Mailer
baseURL string
limiter *SendLimiter
now func() time.Time
store *Store
mailer Mailer
now func() time.Time
}
// NewEmailService constructs an EmailService over store, sending via mailer. baseURL
// is the canonical public origin (scheme + host) used to build the one-tap confirm
// 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 and one-tap deeplink token for (accountID,
// email), replaces any prior pending confirmation, and mails the branded code in
// locale; purpose selects the email wording and what verifying does. Only the SHA-256
// hashes of the code and token are 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
}
token, tokenHash, err := generateLinkToken()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
return err
}
// Account deletion is never a one-tap link (a prefetch or a stray click must not delete
// an account): the delete code is entered in the app only, so the email omits the
// deeplink and ConfirmByToken refuses a delete token.
deeplink := s.confirmURL(token, locale)
if purpose == purposeDelete {
deeplink = ""
}
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
if err != nil {
return err
}
msg.To = email
return s.mailer.Send(ctx, msg)
}
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
// when no public base URL is configured. The locale rides the fragment as ?lang so the
// confirm screen (opened in a browser with no session) renders in the email's language.
func (s *EmailService) confirmURL(token, locale string) string {
if s.baseURL == "" {
return ""
}
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
}
// 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
// NewEmailService constructs an EmailService over store, sending via mailer.
func NewEmailService(store *Store, mailer Mailer) *EmailService {
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }}
}
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
@@ -162,9 +73,6 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return err
@@ -175,7 +83,16 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
}
return ErrEmailTaken
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
code, hash, err := generateCode()
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
@@ -206,35 +123,36 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
// Binding the first confirmed email promotes a guest to a durable account, matching the
// link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set).
if err := s.store.ClearGuest(ctx, accountID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
// RequestLoginCode issues a login confirm-code to the account that owns email,
// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
// durable once the code is confirmed. It is the unauthenticated email-login entry
// point and, unlike RequestCode, does not refuse an already-confirmed email — that is
// the ordinary returning-user login. The code is mailed to the address, so only its
// real owner can complete the login. On first contact browserTZ (the client's
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
// language. It returns the target account id for the subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
// the unauthenticated email-login entry point and, unlike RequestCode,
// does not refuse an already-confirmed email — that is the ordinary returning-user
// login. The code is mailed to the address, so only its real owner can complete
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
// seeds the new account's time zone. It returns the target account id for the
// subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.UUID{}, err
}
if !s.allowSend(addr) {
return uuid.UUID{}, ErrTooManyRequests
}
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
if err != nil {
return uuid.UUID{}, err
}
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
code, hash, err := generateCode()
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 acc.ID, nil
@@ -273,104 +191,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 {
return Account{}, err
}
if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, acc.ID)
}
// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
// transport layer must finish. Purpose is the pending row's purpose. For a login,
// Account is the account to sign in. For a link, Account is the account the email was
// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
// already owns the address, so the caller drives the interactive merge instead of a
// plain link — the token is left unconsumed for that merge step.
type LinkConfirmation struct {
Purpose string
Account uuid.UUID
NeedsMerge bool
MergeOwner uuid.UUID
}
// IsLogin reports whether the confirmation is a login (the caller mints a session)
// rather than a link (attach the identity, or drive a merge).
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
// confirms the email identity, clears the guest flag and returns the account to sign
// in. A link attaches the confirmed email to the pending account when the address is
// free, or reports NeedsMerge when another account already owns it (leaving the token
// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
// is high-entropy, so there is no wrong-attempt counter.
func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
if err != nil {
return LinkConfirmation{}, err
}
if s.now().After(pend.expiresAt) {
return LinkConfirmation{}, ErrCodeExpired
}
switch pend.purpose {
case purposeLogin:
if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
case purposeLink:
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
if err != nil {
return LinkConfirmation{}, err
}
if ok {
if owner == pend.accountID {
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
}
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
// Binding the first email promotes a guest to a durable account, matching the
// code-based link flow (which clears the guest flag in the link service).
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
case purposeChange:
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
if err != nil {
return LinkConfirmation{}, err
}
if ok && owner != pend.accountID {
// The new address is confirmed by a different account: refuse without
// disclosing it (anti-enumeration). Unlike a link, a change never merges.
return LinkConfirmation{}, ErrEmailTaken
}
if ok && owner == pend.accountID {
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
}
if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
case purposeDelete:
// Deletion is confirmed in the app with the code, never via a one-tap link.
return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link")
default:
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
}
}
// emailConfirmation is a pending confirm-code row in domain form.
type emailConfirmation struct {
id uuid.UUID
@@ -399,30 +222,9 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
return row.AccountID, true, nil
}
// confirmedEmailOf returns the account's confirmed email address and true, or ("", false)
// when it holds none. It backs the deletion step-up, which mails a code to the account's
// own address.
func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
stmt := postgres.SELECT(table.Identities.ExternalID).
FROM(table.Identities).
WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
).LIMIT(1)
var row model.Identities
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err)
}
return row.ExternalID, true, nil
}
// replacePendingConfirmation clears any pending code for (accountID, email) and
// inserts a fresh one, inside one transaction.
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new confirmation id: %w", err)
@@ -439,8 +241,7 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U
ins := table.EmailConfirmations.INSERT(
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
).VALUES(id, accountID, email, codeHash, expiresAt)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert confirmation: %w", err)
}
@@ -473,54 +274,6 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU
}, nil
}
// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in
// domain form.
type pendingConfirmation struct {
id uuid.UUID
accountID uuid.UUID
email string
purpose string
expiresAt time.Time
}
// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to
// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so
// a partial-unique index guarantees at most one match.
func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) {
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
FROM(table.EmailConfirmations).
WHERE(
table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)).
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
).LIMIT(1)
var row model.EmailConfirmations
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return pendingConfirmation{}, ErrNoPendingCode
}
return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err)
}
return pendingConfirmation{
id: row.ConfirmationID,
accountID: row.AccountID,
email: row.Email,
purpose: row.Purpose,
expiresAt: row.ExpiresAt,
}, nil
}
// consumeConfirmation marks a confirmation consumed without writing an identity, used
// for the idempotent already-linked deeplink path.
func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error {
upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: consume confirmation: %w", err)
}
return nil
}
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
stmt := table.EmailConfirmations.
@@ -567,69 +320,6 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
return nil
}
// replaceEmailIdentity consumes the confirmation, deletes the account's existing email
// identity (freeing the old address) and inserts newEmail as its confirmed email, inside
// one transaction. It backs the change-email flow. A unique-constraint violation — the
// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When
// the account holds no email identity yet the delete is a no-op, so this doubles as an
// attach.
func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail string, now time.Time) error {
identityID, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
upd := table.EmailConfirmations.
UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("consume confirmation: %w", err)
}
// Journal the outgoing email before replacing it, so the legal dossier keeps the
// address the account used to hold (see retention.go).
var old model.Identities
sel := postgres.SELECT(
table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt,
).FROM(table.Identities).WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
).LIMIT(1)
switch err := sel.QueryContext(ctx, tx, &old); {
case err == nil:
if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil {
return err
}
case errors.Is(err, qrm.ErrNoRows):
// No prior email (this doubles as an attach); nothing to retain.
default:
return fmt.Errorf("load outgoing email identity: %w", err)
}
del := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("delete old email identity: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(identityID, accountID, KindEmail, newEmail, true)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return err
}
return nil
})
if err != nil {
if isUniqueViolation(err) {
return ErrEmailTaken
}
return fmt.Errorf("account: replace email identity: %w", err)
}
return nil
}
// confirmEmailLogin consumes the login code and marks the existing email
// identity confirmed, inside one transaction. The identity already exists (a
// login provisioned it), so this updates rather than inserts and is idempotent
@@ -677,18 +367,6 @@ func generateCode() (code, hash string, err error) {
return code, hashCode(code), nil
}
// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe
// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token
// travels only in the emailed link, mirroring the session-token model.
func generateLinkToken() (token, hash string, err error) {
buf := make([]byte, linkTokenBytes)
if _, err := crand.Read(buf); err != nil {
return "", "", fmt.Errorf("account: generate link token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(buf)
return token, hashCode(token), nil
}
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
func hashCode(code string) string {
sum := sha256.Sum256([]byte(code))
-239
View File
@@ -1,239 +0,0 @@
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: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
},
},
purposeChange: {
"en": {
Subject: "Confirm your new Erudit e-mail",
Preheader: "Confirm your new address",
Heading: "Confirm your new e-mail",
Intro: "Enter this code to switch your account to this address:",
CTALabel: "Confirm with one tap",
FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.",
},
"ru": {
Subject: "Подтвердите новый e-mail в Эрудит",
Preheader: "Подтвердите новый адрес",
Heading: "Смена e-mail",
Intro: "Введите этот код, чтобы привязать аккаунт к новому адресу:",
CTALabel: "Подтвердить одним нажатием",
FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.",
},
},
purposeDelete: {
"en": {
Subject: "Confirm your Erudit account deletion",
Preheader: "Confirm account deletion",
Heading: "Delete your account",
Intro: "Enter this code in the app to permanently delete your account:",
CTALabel: "",
FooterIgnore: "If you didn't request this, ignore it — your account stays as it is.",
},
"ru": {
Subject: "Подтвердите удаление аккаунта Эрудит",
Preheader: "Подтверждение удаления аккаунта",
Heading: "Удаление аккаунта",
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}}
`))
@@ -1,54 +0,0 @@
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"},
{"change ru", purposeChange, "ru", "новый"},
{"change en", purposeChange, "en", "new"},
}
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)
}
}
+9 -160
View File
@@ -2,7 +2,6 @@ package account
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -17,68 +16,6 @@ import (
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// ErrLastIdentity is returned when removing an identity would leave the account with
// none, making it unreachable after logout. The admin email-erase refuses it.
var ErrLastIdentity = errors.New("account: cannot remove the last identity")
// RemoveIdentity deletes the account's identity of the given kind (and, for an email,
// any pending confirmations for it), freeing it for reuse. It refuses when that is the
// account's only identity (ErrLastIdentity) — which would leave the account
// unreachable — and returns ErrNotFound when the account has no identity of that kind.
// It backs the profile Unlink control and the admin "erase email" action.
func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind string) error {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return err
}
var toRetain []Identity
others := 0
for _, id := range ids {
if id.Kind == kind {
toRetain = append(toRetain, id)
} else {
others++
}
}
if len(toRetain) == 0 {
return ErrNotFound
}
if others == 0 {
return ErrLastIdentity
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
// Journal the detached credential before removing it, so the legal dossier
// survives while the identity frees for reuse (see retention.go).
for _, id := range toRetain {
if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil {
return err
}
}
delID := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(kind))),
)
if _, err := delID.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete %s identity %s: %w", kind, accountID, err)
}
if kind == KindEmail {
delConf := table.EmailConfirmations.DELETE().WHERE(
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)),
)
if _, err := delConf.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err)
}
}
return nil
})
}
// RemoveEmailIdentity erases the account's email identity. It backs the admin console's
// "erase email" action; the user-facing profile never unlinks email (it is changed).
func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
return s.RemoveIdentity(ctx, accountID, KindEmail)
}
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
@@ -89,10 +26,16 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
code, hash, err := generateCode()
if err != nil {
return err
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
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)
}
// ConfirmLink verifies code for (accountID, email) and reports the address's
@@ -127,100 +70,6 @@ func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, ema
return accountID, true, nil
}
// RequestChangeCode issues and mails a confirm-code to newEmail for an authenticated
// email change on accountID, replacing any prior pending code. Like RequestLinkCode it
// never refuses up front on "taken" (anti-enumeration): possession of newEmail is the
// authorization, and a conflict with another account is revealed only at confirm — as a
// non-disclosing refusal, never a merge.
func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUID, newEmail string) error {
addr, err := normalizeEmail(newEmail)
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
}
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
// account's confirmed email with newEmail, freeing the old address. When newEmail is
// already confirmed by another account it refuses with ErrEmailTaken (surfaced to the
// user as a non-disclosing "check the address or contact support"), never merging; when
// the account already owns newEmail it is an idempotent no-op. It returns the usual
// confirm-code errors (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts,
// ErrCodeMismatch) and the updated account on success.
func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, newEmail, code string) (Account, error) {
addr, err := normalizeEmail(newEmail)
if err != nil {
return Account{}, err
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return Account{}, err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return Account{}, err
}
if ok && owner != accountID {
return Account{}, ErrEmailTaken
}
if ok && owner == accountID {
if err := s.store.consumeConfirmation(ctx, conf.id, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
if err := s.store.replaceEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
// HasEmail reports whether accountID owns a confirmed email. The account-deletion step-up
// mails a confirm-code when it does, and falls back to a typed phrase otherwise.
func (s *EmailService) HasEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
_, ok, err := s.store.confirmedEmailOf(ctx, accountID)
return ok, err
}
// RequestDeleteCode mails an account-deletion confirm-code to the account's own confirmed
// email (no deeplink — deletion is confirmed in the app). It returns ErrNoEmail when the
// account holds no email, ErrTooManyRequests when throttled.
func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUID) error {
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
if err != nil {
return err
}
if !ok {
return ErrNoEmail
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID))
}
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
// consumes it on success. It returns ErrNoEmail (no email), the usual confirm-code errors
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch), or nil when the
// code is valid — the caller then performs the deletion.
func (s *EmailService) VerifyDeleteCode(ctx context.Context, accountID uuid.UUID, code string) error {
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
if err != nil {
return err
}
if !ok {
return ErrNoEmail
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return err
}
return s.store.consumeConfirmation(ctx, conf.id, s.now())
}
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
// addr), counting a wrong attempt. It returns the confirmation on success.
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
+28 -121
View File
@@ -3,99 +3,33 @@ package account
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"net"
"net/smtp"
"github.com/wneessen/go-mail"
"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 is the recipient address, or several comma-separated (all get the one message).
To string
// From, when non-empty, overrides the configured sender for this message — the admin
// alert path uses a distinct From from the user-facing confirm-code sender.
From string
Subject string
Text string
HTML string
}
// 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
// fixture (see docs/TESTING.md: no real network in tests). The context bounds the
// delivery and is honoured by the SMTP implementation.
// fixture (see docs/TESTING.md: no real network in tests). The context is offered
// for cancellation; the standard-library SMTP implementation sends synchronously
// and ignores it.
type Mailer interface {
Send(ctx context.Context, msg Message) error
}
// splitAddrs splits a comma-separated recipient list into trimmed, non-empty addresses.
func splitAddrs(list string) []string {
parts := strings.Split(list, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if a := strings.TrimSpace(p); a != "" {
out = append(out, a)
}
}
return out
Send(ctx context.Context, to, subject, body string) error
}
// 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).
// TLS is always used and no client certificate is required — only the server
// certificate is validated against the system roots.
type SMTPConfig struct {
Host string
Port string
Username string
Password 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
// AdminFrom / AdminTo drive the operator alert emails (new feedback / word complaints),
// distinct from the user-facing confirm-code sender. AdminTo may be several
// comma-separated addresses. Both empty disables the alert worker.
AdminFrom string
AdminTo string
}
const (
// 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.
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
type SMTPMailer struct {
cfg SMTPConfig
}
@@ -105,55 +39,29 @@ func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
return SMTPMailer{cfg: cfg}
}
// Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
// is set the message is multipart/alternative (plain text plus HTML); otherwise
// it is plain text only.
func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
port, err := strconv.Atoi(m.cfg.Port)
if err != nil {
return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err)
}
opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)}
if m.cfg.tlsMode(port) == smtpTLSImplicit {
opts = append(opts, mail.WithSSL())
} else {
opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory))
}
// Send delivers a plain-text UTF-8 message to to via the configured relay.
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
var auth smtp.Auth
if m.cfg.Username != "" {
opts = append(opts,
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
mail.WithUsername(m.cfg.Username),
mail.WithPassword(m.cfg.Password),
)
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
}
client, err := mail.NewClient(m.cfg.Host, opts...)
if err != nil {
return fmt.Errorf("account: build mail client: %w", err)
}
out := mail.NewMsg()
from := m.cfg.From
if msg.From != "" {
from = msg.From
}
if err := out.From(from); err != nil {
return fmt.Errorf("account: set From %q: %w", from, err)
}
// To may carry several comma-separated recipients; go-mail wants them as separate
// arguments (a single joined string parses as one malformed address).
if err := out.To(splitAddrs(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)
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil {
return fmt.Errorf("account: send mail to %s: %w", to, err)
}
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
// 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.
@@ -166,12 +74,11 @@ func NewLogMailer(log *zap.Logger) LogMailer {
return LogMailer{log: log}
}
// Send logs the message at info level and reports success. It logs the plain-text
// body only (which carries the confirm-code); the HTML alternative is omitted.
func (m LogMailer) Send(_ context.Context, msg Message) error {
// Send logs the message at info level and reports success.
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
if m.log != nil {
m.log.Info("email not sent (log mailer)",
zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
zap.String("to", to), zap.String("subject", subject), zap.String("body", body))
}
return nil
}
-51
View File
@@ -1,51 +0,0 @@
package account
import (
"slices"
"testing"
)
// TestSplitAddrs covers the comma-separated recipient parsing used for the admin alert
// To (several operator mailboxes in one message), including trimming and empty entries.
func TestSplitAddrs(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"a@x.ru", []string{"a@x.ru"}},
{"a@x.ru, b@y.ru", []string{"a@x.ru", "b@y.ru"}},
{" a@x.ru ,, b@y.ru ,", []string{"a@x.ru", "b@y.ru"}},
{"", nil},
}
for _, c := range cases {
if got := splitAddrs(c.in); !slices.Equal(got, c.want) {
t.Errorf("splitAddrs(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// 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)
}
})
}
}
-66
View File
@@ -1,66 +0,0 @@
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
}
@@ -1,43 +0,0 @@
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")
}
}
-224
View File
@@ -1,224 +0,0 @@
package account
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// RetentionTTL bounds how long the account-deletion legal dossier is kept before the
// reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03).
const RetentionTTL = 2 * 365 * 24 * time.Hour
// Reasons recorded on a retained_identities row: what detached the credential from its
// account (unlink / email change / account deletion here; an account merge that drops a
// same-kind colliding identity writes reason "merge" from the accountmerge package). The
// row is written just before the live identities row is removed, preserving the legal
// dossier (which email/vk/tg was linked, and when) even as the identity frees for reuse.
// See docs/ARCHITECTURE.md §9.1.
const (
retainUnlink = "unlink"
retainChange = "change"
retainDelete = "delete"
)
// retainIdentityTx appends a retention-journal row for one identity being detached, inside
// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It
// must run in the same transaction as the identity removal, so the dossier and the live
// state can never diverge.
func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.Reason,
).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err)
}
return nil
}
// StampLastLogin records the account's last cold-load time and client IP, but only when
// the stored value is missing or older than an hour — so it costs at most one write per
// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a
// best-effort audit signal that feeds the account-deletion dossier.
func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error {
now := time.Now().UTC()
upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP).
SET(postgres.TimestampzT(now), postgres.String(ip)).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(
table.Accounts.LastLoginAt.IS_NULL().
OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))),
),
)
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: stamp last login %s: %w", accountID, err)
}
return nil
}
// ReapExpiredRetention purges retention data whose event is older than cutoff: every
// retained_identities row by its detached_at (covering unlink/change on live accounts as
// well as deleted ones), plus — for accounts tombstoned before cutoff — the retained
// feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept
// (a shared game artifact), and the tombstone account row itself stays (its no-cascade
// foreign keys). It returns how many journal rows and feedback messages were removed.
func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) {
cut := postgres.TimestampzT(cutoff)
delJournal := table.RetainedIdentities.DELETE().
WHERE(table.RetainedIdentities.DetachedAt.LT(cut))
res, err := delJournal.ExecContext(ctx, s.db)
if err != nil {
return 0, 0, fmt.Errorf("account: reap retained identities: %w", err)
}
identities, _ = res.RowsAffected()
expired := postgres.SELECT(table.Accounts.AccountID).
FROM(table.Accounts).
WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut)))
delFeedback := table.FeedbackMessages.DELETE().
WHERE(table.FeedbackMessages.AccountID.IN(expired))
fbRes, err := delFeedback.ExecContext(ctx, s.db)
if err != nil {
return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err)
}
feedback, _ = fbRes.RowsAffected()
clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP).
SET(postgres.NULL, postgres.NULL).
WHERE(
table.Accounts.DeletedAt.IS_NOT_NULL().
AND(table.Accounts.DeletedAt.LT(cut)).
AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL().
OR(table.Accounts.LastLoginIP.IS_NOT_NULL())),
)
if _, err := clearPII.ExecContext(ctx, s.db); err != nil {
return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err)
}
return identities, feedback, nil
}
// RetainedIdentity is one row of the retention journal, for the admin dossier.
type RetainedIdentity struct {
Kind string
ExternalID string
Reason string
Confirmed bool
LinkedAt time.Time
DetachedAt time.Time
}
// RetainedIdentities returns the account's retention-journal rows (the legal dossier of
// detached credentials), newest detach first, for the admin console.
func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) {
var rows []model.RetainedIdentities
err := postgres.SELECT(table.RetainedIdentities.AllColumns).
FROM(table.RetainedIdentities).
WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()).
QueryContext(ctx, s.db, &rows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err)
}
out := make([]RetainedIdentity, 0, len(rows))
for _, r := range rows {
out = append(out, RetainedIdentity{
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt,
})
}
return out, nil
}
// DeletionInfo is a tombstoned account's dossier header, for the admin console.
type DeletionInfo struct {
DeletedAt *time.Time
DeletedDisplayName string
LastLoginAt *time.Time
LastLoginIP string
}
// DeletionInfo reads the account's deletion tombstone + last-login dossier fields.
func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) {
var row model.Accounts
err := postgres.SELECT(
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
table.Accounts.LastLoginAt, table.Accounts.LastLoginIP,
).FROM(table.Accounts).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, s.db, &row)
if err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return DeletionInfo{}, ErrNotFound
}
return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err)
}
info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt}
if row.DeletedDisplayName != nil {
info.DeletedDisplayName = *row.DeletedDisplayName
}
if row.LastLoginIP != nil {
info.LastLoginIP = *row.LastLoginIP
}
return info, nil
}
// RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main.
type RetentionReaper struct {
store *Store
ttl time.Duration
clock func() time.Time
log *zap.Logger
}
// NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be
// nil.
func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper {
if log == nil {
log = zap.NewNop()
}
return &RetentionReaper{
store: store,
ttl: ttl,
clock: func() time.Time { return time.Now().UTC() },
log: log,
}
}
// Run purges expired retention data on each tick until ctx is cancelled.
func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl))
if err != nil {
r.log.Warn("retention reap failed", zap.Error(err))
} else if idn > 0 || fb > 0 {
r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb))
}
}
}
}
+12 -44
View File
@@ -19,9 +19,6 @@ type UserListItem struct {
PreferredLanguage string
IsGuest bool
IsRobot bool
// IsDeleted marks a tombstoned account (deleted_at set), shown as a badge — a search
// spans both lists, so a result can be either live or deleted.
IsDeleted bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
// as a badge in the console list.
FlaggedHighRateAt time.Time
@@ -29,17 +26,13 @@ type UserListItem struct {
}
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them);
// NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched
// case-insensitively against the display name / any identity's external id; EmailExact is a
// strict (exact) match against an account's email identity. An empty value means no filter
// on that field.
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
// id. An empty mask means no filter on that field.
type UserFilter struct {
Robots bool
Deleted bool
NameMask string
ExternalIDMask string
EmailExact string
}
// robotExists is the correlated subquery testing whether account a is a robot.
@@ -58,42 +51,17 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
return ok, nil
}
// userListWhere builds the shared WHERE clause and its positional args (from $1). On the
// Robots tab it lists/searches robots only. Otherwise a search (any of the name /
// external-id / email filters) spans live and deleted people alike — never robots — so the
// operator finds a match from one query regardless of the People / Deleted tab; the search
// also looks in the retention journal, so a deleted account is still found by the email /
// external id it held (those rows moved from identities to retained_identities on deletion)
// and by its retained real name. With no search, the People / Deleted tab scope applies.
// userListWhere builds the shared WHERE clause and its positional args (from $1).
func userListWhere(f UserFilter) (string, []any) {
name := LikePattern(f.NameMask)
ext := LikePattern(f.ExternalIDMask)
email := strings.ToLower(strings.TrimSpace(f.EmailExact))
searching := name != "" || ext != "" || email != ""
var args []any
var where string
switch {
case f.Robots:
where = robotExists + ` = true`
case searching:
where = robotExists + ` = false`
case f.Deleted:
where = robotExists + ` = false AND a.deleted_at IS NOT NULL`
default:
where = robotExists + ` = false AND a.deleted_at IS NULL`
}
if name != "" {
args := []any{f.Robots}
where := robotExists + ` = $1`
if name := LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND (a.display_name ILIKE $%d ESCAPE '\' OR a.deleted_display_name ILIKE $%d ESCAPE '\')`, len(args), len(args))
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext != "" {
if ext := LikePattern(f.ExternalIDMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\') OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.external_id ILIKE $%d ESCAPE '\'))`, len(args), len(args))
}
if email != "" {
args = append(args, email)
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d) OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.kind = 'email' AND r.external_id = $%d))`, len(args), len(args))
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
return where, args
}
@@ -101,7 +69,7 @@ func userListWhere(f UserFilter) (string, []any) {
// ListUsers returns the filtered admin user list, newest first, paginated.
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
where, args := userListWhere(f)
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot, (a.deleted_at IS NOT NULL) AS is_deleted
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
FROM backend.accounts a WHERE ` + where +
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
@@ -114,7 +82,7 @@ FROM backend.accounts a WHERE ` + where +
for rows.Next() {
var it UserListItem
var flagged sql.NullTime
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil {
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
return nil, fmt.Errorf("account: scan user: %w", err)
}
if flagged.Valid {
-223
View File
@@ -1,223 +0,0 @@
// Package accountdelete deactivates an account as legal retention, not erasure: it keeps
// the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a
// hard delete is impossible) while journalling and freeing the account's credentials,
// anonymising the live surfaces, and dropping the account's own social/ephemeral rows.
// The retained_identities journal plus the tombstone (deleted_at, deleted_display_name,
// last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session
// revocation and active-game forfeit are orchestrated one layer up (they need the session
// cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper.
package accountdelete
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// AnonymizedName is the label a deleted account shows to opponents. Display names are
// stored strings resolved identically for every viewer (no per-viewer localisation in this
// codebase), so a single canonical label is used. The brackets are deliberate: the
// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a
// name that impersonates a deleted account.
const AnonymizedName = "[Deleted]"
// retainDelete is the retained_identities reason written when a credential is journalled
// because its account is being deleted.
const retainDelete = "delete"
// Deleter performs the SQL-atomic part of account deletion over a Postgres handle.
type Deleter struct {
db *sql.DB
now func() time.Time
}
// NewDeleter constructs a Deleter over db.
func NewDeleter(db *sql.DB) *Deleter {
return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }}
}
// AnonymizeAndTombstone retires accountID atomically: it journals every live identity into
// retained_identities (reason=delete) then removes them so the credentials free for reuse,
// snapshots the real display name into deleted_display_name and scrubs the live one to
// AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops
// its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat,
// feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign
// keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling
// nothing, since the identities are already gone).
func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error {
now := d.now()
return withTx(ctx, d.db, func(tx *sql.Tx) error {
if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil {
return err
}
if err := tombstone(ctx, tx, accountID, now); err != nil {
return err
}
if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName).
SET(postgres.String(AnonymizedName)).
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: anonymise seats: %w", err)
}
return dropSocialAndEphemerals(ctx, tx, accountID)
})
}
// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a
// human — a robot seat is one whose account holds a 'robot' identity, so this covers both
// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their
// moves/chat/players/complaints fall away through ON DELETE CASCADE.
const dropAllRobotGamesSQL = `
DELETE FROM games g
WHERE EXISTS (
SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1
) AND NOT EXISTS (
SELECT 1 FROM game_players o
WHERE o.game_id = g.game_id AND o.account_id <> $1
AND NOT EXISTS (
SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot'
)
)`
// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or
// auto-match-robot games), returning how many were removed. Games with any human seat are
// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the
// account's active games are resigned, so no live game is removed under the robot driver.
func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) {
res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID)
if err != nil {
return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("accountdelete: dropped games count: %w", err)
}
return n, nil
}
// journalAndDropIdentities copies the account's live identities into the retention journal
// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse.
func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
var ids []model.Identities
err := postgres.SELECT(table.Identities.AllColumns).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, tx, &ids)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountdelete: load identities: %w", err)
}
for _, id := range ids {
rid, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("accountdelete: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.DetachedAt, table.RetainedIdentities.Reason,
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err)
}
}
if _, err := table.Identities.DELETE().
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete identities: %w", err)
}
return nil
}
// tombstone marks the account deleted, snapshotting the real display name into
// deleted_display_name (evaluated from the old row) before scrubbing the live one.
func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
upd := table.Accounts.UPDATE(
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
table.Accounts.DisplayName, table.Accounts.UpdatedAt,
).SET(
postgres.TimestampzT(now), table.Accounts.DisplayName,
postgres.String(AnonymizedName), postgres.TimestampzT(now),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: tombstone account: %w", err)
}
return nil
}
// dropSocialAndEphemerals removes the account's own friendships, blocks, invitations
// (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are
// the deleting user's private data with no dossier value; chat and feedback are kept.
func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error {
id := postgres.UUID(accountID)
// Friendships and blocks are two-account edges keyed on either endpoint.
if _, err := table.Friendships.DELETE().
WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete friendships: %w", err)
}
if _, err := table.Blocks.DELETE().
WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete blocks: %w", err)
}
// Invitations: drop the account's invitee rows, then its own invitations' invitees and
// the invitations themselves (children first, to respect the foreign key).
if _, err := table.GameInvitationInvitees.DELETE().
WHERE(table.GameInvitationInvitees.AccountID.EQ(id)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete invitee rows: %w", err)
}
ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID).
FROM(table.GameInvitations).
WHERE(table.GameInvitations.InviterID.EQ(id))
if _, err := table.GameInvitationInvitees.DELETE().
WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err)
}
if _, err := table.GameInvitations.DELETE().
WHERE(table.GameInvitations.InviterID.EQ(id)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete invitations: %w", err)
}
// Ephemerals: friend codes, move drafts, pending confirm-codes.
if _, err := table.FriendCodes.DELETE().
WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete friend codes: %w", err)
}
if _, err := table.GameDrafts.DELETE().
WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete drafts: %w", err)
}
if _, err := table.EmailConfirmations.DELETE().
WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete confirmations: %w", err)
}
return nil
}
// withTx runs fn inside a transaction, committing on success and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("accountdelete: begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("accountdelete: commit tx: %w", err)
}
return nil
}
-79
View File
@@ -27,11 +27,6 @@ import (
// without taking a dependency on the game package.
const statusActive = "active"
// retainReasonMerge is the retained_identities.reason for a credential dropped by a merge
// collision (both accounts held the same kind). It mirrors the account package's retain
// reasons, kept local to avoid importing that package's unexported constants.
const retainReasonMerge = "merge"
// Friendship statuses, highest precedence first, mirroring internal/social.
const (
friendAccepted = "accepted"
@@ -80,9 +75,6 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := dedupeIdentities(ctx, tx, primary, secondary); err != nil {
return err
}
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: identities: %w", err)
}
@@ -308,77 +300,6 @@ func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col pos
return err
}
// dedupeIdentities resolves a same-kind identity collision before the blanket identity
// reassign: when both accounts already hold an identity of the same kind (e.g. each has a
// confirmed email — reachable when two email-bearing accounts merge), the primary keeps
// its own and the secondary's is journaled to retained_identities (reason=merge) and
// removed. Without this the blanket reassign would leave the survivor with two identities
// of one kind (there is no per-account-kind unique on identities), which the profile and
// the retention dossier both treat as singular. Non-colliding identities are untouched and
// move with the blanket reassign.
func dedupeIdentities(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
var prows []model.Identities
if err := postgres.SELECT(table.Identities.Kind).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(primary))).
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: primary identity kinds: %w", err)
}
occupied := make(map[string]struct{}, len(prows))
for _, r := range prows {
occupied[r.Kind] = struct{}{}
}
if len(occupied) == 0 {
return nil
}
var srows []model.Identities
if err := postgres.SELECT(
table.Identities.Kind, table.Identities.ExternalID,
table.Identities.Confirmed, table.Identities.CreatedAt,
).FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: secondary identities: %w", err)
}
for _, s := range srows {
if _, dup := occupied[s.Kind]; !dup {
continue
}
if err := retainMergedIdentity(ctx, tx, secondary, s); err != nil {
return err
}
del := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(secondary)).
AND(table.Identities.Kind.EQ(postgres.String(s.Kind))).
AND(table.Identities.ExternalID.EQ(postgres.String(s.ExternalID))),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: drop colliding %s identity: %w", s.Kind, err)
}
}
return nil
}
// retainMergedIdentity appends a retained_identities row for a secondary identity dropped
// by a merge collision (reason=merge), preserving it in the legal dossier. It mirrors
// account.retainIdentityTx, which is unexported; detached_at falls to the column default.
func retainMergedIdentity(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, id model.Identities) error {
rid, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("accountmerge: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.Reason,
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainReasonMerge)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: retain merged %s identity: %w", id.Kind, err)
}
return nil
}
// friendRank ranks a friendship status for dedupe precedence (higher wins).
func friendRank(status string) int {
switch status {
-116
View File
@@ -1,116 +0,0 @@
// Package adminalert emails the operator when new player feedback or word complaints
// arrive, coalescing a burst into a single digest per interval so a flood is one email,
// not N. It is inert unless an admin sender and recipient are configured. The sender is
// distinct from the user-facing confirm-code From, and the recipient may be several
// comma-separated addresses (the mailer splits them).
package adminalert
import (
"context"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
// FeedbackCounter counts feedback created since a time (satisfied by feedback.Service).
type FeedbackCounter interface {
CountSince(ctx context.Context, since time.Time) (int, error)
}
// ComplaintCounter counts word complaints filed since a time (satisfied by game.Service).
type ComplaintCounter interface {
CountComplaintsSince(ctx context.Context, since time.Time) (int, error)
}
// Notifier polls for new feedback and complaints and emails the operator a digest.
type Notifier struct {
mailer account.Mailer
feedback FeedbackCounter
complaints ComplaintCounter
from string
to string
consoleURL string
clock func() time.Time
log *zap.Logger
last time.Time
}
// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
// when non-empty, is the admin-console link included in the email. log may be nil. The
// watermark starts at "now", so only items arriving after start-up are reported.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
if log == nil {
log = zap.NewNop()
}
return &Notifier{
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
}
}
// Run polls on each tick until ctx is cancelled.
func (n *Notifier) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n.tick(ctx)
}
}
}
// tick counts what arrived since the last watermark and, if anything did, emails one
// digest. The watermark only advances after a successful send (or a quiet tick), so a
// transient send failure is retried on the next tick — the counts simply grow.
func (n *Notifier) tick(ctx context.Context) {
now := n.clock()
fb, err := n.feedback.CountSince(ctx, n.last)
if err != nil {
n.log.Warn("admin alert: count feedback failed", zap.Error(err))
return
}
cp, err := n.complaints.CountComplaintsSince(ctx, n.last)
if err != nil {
n.log.Warn("admin alert: count complaints failed", zap.Error(err))
return
}
if fb == 0 && cp == 0 {
n.last = now
return
}
if err := n.mailer.Send(ctx, n.digest(fb, cp)); err != nil {
n.log.Warn("admin alert: send failed", zap.Error(err))
return
}
n.log.Info("admin alert sent", zap.Int("feedback", fb), zap.Int("complaints", cp))
n.last = now
}
// digest builds the operator alert email for fb new feedback and cp new complaints.
func (n *Notifier) digest(fb, cp int) account.Message {
var parts []string
if fb > 0 {
parts = append(parts, fmt.Sprintf("%d new feedback message(s)", fb))
}
if cp > 0 {
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
}
summary := strings.Join(parts, ", ")
text := summary + "."
if n.consoleURL != "" {
text += "\n\nOpen the admin console: " + n.consoleURL
}
return account.Message{
From: n.from,
To: n.to,
Subject: "Erudit — " + summary,
Text: text,
}
}
@@ -1,55 +0,0 @@
package adminalert
import (
"context"
"strings"
"testing"
"time"
"scrabble/backend/internal/account"
)
// The fakes ignore the watermark and return a fixed count, which is all the digest logic
// needs.
type fbCounter struct{ n int }
func (f fbCounter) CountSince(context.Context, time.Time) (int, error) { return f.n, nil }
type cpCounter struct{ n int }
func (c cpCounter) CountComplaintsSince(context.Context, time.Time) (int, error) { return c.n, nil }
type recordingMailer struct{ sent []account.Message }
func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
m.sent = append(m.sent, msg)
return nil
}
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil)
n.tick(context.Background())
if len(mailer.sent) != 0 {
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
}
}
func TestNotifierDigestsNewItems(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil)
n.tick(context.Background())
if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
}
msg := mailer.sent[0]
if msg.From != "alerts@erudit-game.ru" || msg.To != "op@x.ru, two@x.ru" {
t.Errorf("digest addressing = From %q To %q", msg.From, msg.To)
}
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
}
if !strings.Contains(msg.Text, "/_gm") {
t.Errorf("digest body = %q, want the console link", msg.Text)
}
}
@@ -193,24 +193,3 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
.replay-log li.cur { color: var(--ink); font-weight: 600; }
/* Banner colour override editor + live preview (banner_detail). The override
fieldsets group the enable toggle with the native colour swatches; the preview
renders a sample strip on both themes from those inputs (see the inline script). */
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
.ovr .note { margin: 0.2rem 0 0.4rem; }
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
.banner-preview .bp { flex: 1 1 18rem; }
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
.ad-frame.light { background: #f4f6f9; }
.ad-frame.dark { background: #0f1420; }
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ad-sample .ad-link { text-decoration: underline; }
@@ -11,72 +11,10 @@
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
<fieldset class="ovr">
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
{{end}}
<div><button type="submit">Save</button></div>
</form>
{{if not .IsDefault}}
<div class="banner-preview">
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
</div>
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
<script>
(function(){
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
function byName(n){return document.querySelector('[name="'+n+'"]');}
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
if(!allOn||!darkOn){return;}
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
function paint(el,c){
el.style.background=c.bg; el.style.color=c.fg;
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
}
function resolve(){
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
paint(lightEl,light); paint(darkEl,dark);
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
if(inp&&hx){hx.textContent=inp.value;}
});
}
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
resolve();
})();
</script>
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
<button type="submit" class="danger">Delete campaign</button>
</form>
@@ -101,29 +101,6 @@
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
</tbody>
</table>
{{if .HasEmail}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/remove-email" onsubmit="return confirm('Erase the email identity from this account? The address will be freed.')">
<button type="submit">Erase email</button>
</form>
{{end}}
</section>
<section class="panel"><h2>Deletion &amp; retention</h2>
{{if .LastLoginAt}}<p class="note">Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — <code>{{.LastLoginIP}}</code>{{end}}</p>{{end}}
{{if .Deleted}}<p><span class="warn">Deleted</span> at {{.DeletedAt}}{{if .DeletedName}} — was <code>{{.DeletedName}}</code>{{end}}</p>{{end}}
{{if .Retained}}
<h3>Retention journal (legal dossier of detached credentials)</h3>
<table class="list">
<thead><tr><th>Kind</th><th>Credential</th><th>Reason</th><th>Detached</th></tr></thead>
<tbody>
{{range .Retained}}<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{.Reason}}</td><td>{{.DetachedAt}}</td></tr>{{end}}
</tbody>
</table>
{{end}}
{{if not .Deleted}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
<button type="submit">Delete user</button>
</form>
{{end}}
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
@@ -2,15 +2,13 @@
<h1>Users</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/users"{{if and (not .Robots) (not .Deleted)}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=deleted"{{if .Deleted}} class="active"{{end}}>Deleted</a> ·
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
</nav>
<form class="form" method="get" action="/_gm/users">
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
<button type="submit">Filter</button>
</form>
<table class="list">
@@ -19,7 +17,7 @@
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
<td>{{.DisplayName}}{{if .Deleted}} <span class="pill">deleted</span>{{end}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.Kind}}</td>
<td>{{.Language}}</td>
<td>{{.CreatedAt}}</td>
+9 -49
View File
@@ -60,10 +60,8 @@ type UsersView struct {
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
// percent-encoded again by the contextual escaper.
Robots bool
Deleted bool
NameMask string
ExternalIDMask string
EmailExact string
FilterQuery template.URL
}
@@ -76,7 +74,6 @@ type UserRow struct {
Kind string
Language string
Guest bool
Deleted bool
FlaggedHighRate bool
CreatedAt string
HasMoveStats bool
@@ -153,15 +150,6 @@ type UserDetailView struct {
// MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account.
MergedInto string
// The account-deletion dossier. Deleted marks a tombstoned account; DeletedAt and
// DeletedName are its deletion time and retained real name; LastLoginAt/IP are the
// last cold-load stamp (shown for any account); Retained is the credential journal.
Deleted bool
DeletedAt string
DeletedName string
LastLoginAt string
LastLoginIP string
Retained []RetainedRow
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string
@@ -173,9 +161,7 @@ type UserDetailView struct {
HasStats bool
Stats StatsRow
Identities []IdentityRow
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
HasEmail bool
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).
@@ -248,17 +234,6 @@ type IdentityRow struct {
CreatedAt string
}
// RetainedRow is one credential in the account-deletion retention journal (the legal
// dossier of detached credentials): what was detached, when, and why.
type RetainedRow struct {
Kind string
ExternalID string
Reason string
Confirmed bool
LinkedAt string
DetachedAt string
}
// GameRow is one game row in a list.
type GameRow struct {
ID string
@@ -498,30 +473,15 @@ type BannerCampaignRow struct {
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
//
// The colour-override and urgent fields drive the non-default campaign's editor:
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
// when a set is on, otherwise the neutral theme token so the picker starts from a
// sensible colour and the live preview shows the real fallback.
type BannerDetailView struct {
ID string
Name string
Weight int
IsDefault bool
Enabled bool
StartsAt string
EndsAt string
Urgent bool
OverrideAllOn bool
AllBg string
AllFg string
AllLink string
OverrideDarkOn bool
DarkBg string
DarkFg string
DarkLink string
Messages []BannerMessageRow
ID string
Name string
Weight int
IsDefault bool
Enabled bool
StartsAt string
EndsAt string
Messages []BannerMessageRow
}
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
+13 -77
View File
@@ -30,15 +30,6 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
// window or message body).
var ErrValidation = errors.New("ads: validation")
// ColorSet is an optional per-campaign colour override for the banner strip:
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
// are set together or the whole set is absent (a nil *ColorSet).
type ColorSet struct {
Bg string
Fg string
Link string
}
// Campaign is one advertising placement order with its messages.
type Campaign struct {
ID uuid.UUID // uuid.Nil on create
@@ -49,16 +40,6 @@ type Campaign struct {
StartsAt *time.Time // nil = open-ended start; always nil for the default
EndsAt *time.Time // nil = open-ended end; always nil for the default
Messages []Message
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
// light ← all ?? token). Both are nil for the default campaign and for a
// campaign that keeps the neutral theme tokens. Non-default only.
OverrideAll *ColorSet
OverrideDark *ColorSet
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
// any urgent campaign is active, suppresses every non-urgent campaign and the
// default remainder. Non-default only; always false for the default campaign.
Urgent bool
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -89,15 +70,10 @@ type Timings struct {
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order, plus the optional colour overrides
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
// carried here: it is resolved server-side into the set's membership and the
// eligibility bypass, so the client only ever renders what it is sent.
// language and in display (round-robin) order.
type ActiveCampaign struct {
Weight int
Messages []string
OverrideAll *ColorSet
OverrideDark *ColorSet
Weight int
Messages []string
}
// Eligible reports whether an account should be shown the advertising banner: a
@@ -109,20 +85,14 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
}
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang, and reports whether the feed is an urgent one.
// Campaigns outside their validity window, and campaigns with no messages, are
// dropped.
//
// While any active campaign is urgent, the feed is the urgent campaigns alone —
// every non-urgent timed campaign and the default remainder are suppressed for
// the duration (and the caller shows the feed to every viewer, bypassing
// eligibility). Otherwise the default campaign's effective weight is the
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
// unsold inventory and is dropped entirely when timed campaigns already reach
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
// disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
// at time now, in language lang. Campaigns outside their validity window, and
// campaigns with no messages, are dropped. The default campaign's effective
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
// weights) — so it fills unsold inventory and is dropped entirely when timed
// campaigns already reach 100%. Weights are then reduced by their GCD so the
// fair rotation cycle stays short. The input is expected to be the enabled
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
var timed []Campaign
var def *Campaign
for i := range campaigns {
@@ -138,54 +108,20 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]Activ
timed = append(timed, c)
}
}
if urgent := filterUrgent(timed); len(urgent) > 0 {
out := make([]ActiveCampaign, 0, len(urgent))
for _, c := range urgent {
out = append(out, activeFrom(c, lang))
}
reduceByGCD(out)
return out, true
}
sumTimed := 0
for _, c := range timed {
sumTimed += c.Weight
}
out := make([]ActiveCampaign, 0, len(timed)+1)
for _, c := range timed {
out = append(out, activeFrom(c, lang))
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
}
if def != nil {
if dw := 100 - sumTimed; dw > 0 {
a := activeFrom(*def, lang)
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
out = append(out, a)
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
}
}
reduceByGCD(out)
return out, false
}
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
// language-resolved messages and its colour overrides (carried through by
// reference, they are read-only).
func activeFrom(c Campaign, lang string) ActiveCampaign {
return ActiveCampaign{
Weight: c.Weight,
Messages: resolveBodies(c.Messages, lang),
OverrideAll: c.OverrideAll,
OverrideDark: c.OverrideDark,
}
}
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
// preempt selector: a non-empty result makes the whole feed urgent.
func filterUrgent(cs []Campaign) []Campaign {
var out []Campaign
for _, c := range cs {
if c.Urgent {
out = append(out, c)
}
}
return out
}
+7 -63
View File
@@ -46,19 +46,12 @@ func TestComputeActiveSet(t *testing.T) {
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
}
urgent := func(name string, weight int, msgs []Message) Campaign {
c := timed(name, weight, nil, nil, msgs)
c.Urgent = true
return c
}
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
tests := []struct {
name string
campaigns []Campaign
lang string
want []ActiveCampaign
wantUrgent bool
name string
campaigns []Campaign
lang string
want []ActiveCampaign
}{
{
name: "default only reduces to weight 1",
@@ -159,71 +152,22 @@ func TestComputeActiveSet(t *testing.T) {
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
},
{
name: "urgent preempts default and normal timed",
campaigns: []Campaign{
def(msg("house")),
timed("promo", 40, nil, nil, msg("promo")),
urgent("alert", 50, msg("alert")),
},
lang: "en",
// only the urgent campaign survives; a lone weight reduces to 1.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
wantUrgent: true,
},
{
name: "multiple urgent share the feed by gcd",
campaigns: []Campaign{
def(msg("house")),
urgent("a", 60, msg("a")),
urgent("b", 40, msg("b")),
},
lang: "en",
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"a-en"}},
{Weight: 2, Messages: []string{"b-en"}},
},
wantUrgent: true,
},
{
name: "out-of-window urgent does not preempt",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
},
lang: "en",
// the urgent campaign is not yet live, so the normal default feed stands.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "colour overrides ride the active campaign",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
got := computeActiveSet(tt.campaigns, now, tt.lang)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
}
if gotUrgent != tt.wantUrgent {
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
}
})
}
}
func TestComputeActiveSetEmpty(t *testing.T) {
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
}
}
+5 -62
View File
@@ -3,7 +3,6 @@ package ads
import (
"context"
"fmt"
"regexp"
"strings"
"time"
@@ -41,20 +40,17 @@ func NewService(store *Store) *Service { return &Service{store: store} }
// ActiveSet returns the resolved rotation feed for a viewer in language lang
// (en/ru) together with the global display timings: the currently-active
// campaigns, each with its GCD-reduced show weight and its messages resolved to
// lang, ready for the client's weighted round-robin. The bool result reports
// whether the feed is urgent — an urgent feed is shown to every viewer
// regardless of eligibility (the caller skips the eligibility gate for it).
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
// lang, ready for the client's weighted round-robin.
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
campaigns, err := s.store.ActiveCampaigns(ctx)
if err != nil {
return nil, Timings{}, false, err
return nil, Timings{}, err
}
timings, err := s.store.Settings(ctx)
if err != nil {
return nil, Timings{}, false, err
return nil, Timings{}, err
}
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
return set, timings, urgent, nil
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
}
// ListCampaigns returns every campaign with its messages, for the admin console.
@@ -80,13 +76,8 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return uuid.Nil, err
}
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return uuid.Nil, err
}
return s.store.CreateCampaign(ctx, Campaign{
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
})
}
@@ -108,7 +99,6 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
upd.Enabled = true
upd.StartsAt = nil
upd.EndsAt = nil
// The default (house) campaign stays plain: no colour overrides, never urgent.
} else {
if err := validWeight(c.Weight); err != nil {
return err
@@ -116,17 +106,10 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return err
}
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return err
}
upd.Weight = c.Weight
upd.Enabled = c.Enabled
upd.StartsAt = c.StartsAt
upd.EndsAt = c.EndsAt
upd.OverrideAll = all
upd.OverrideDark = dark
upd.Urgent = c.Urgent
}
return s.store.UpdateCampaign(ctx, upd)
}
@@ -271,46 +254,6 @@ func validWindow(starts, ends *time.Time) error {
return nil
}
// hexColor matches a "#rrggbb" colour — the format the console's native colour
// input emits and the wire carries.
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// validOverrides validates the two optional colour sets together and returns
// their normalised copies (nil when a set is absent), so a caller can store them
// directly.
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
va, err := validColorSet(all)
if err != nil {
return nil, nil, err
}
vd, err := validColorSet(dark)
if err != nil {
return nil, nil, err
}
return va, vd, nil
}
// validColorSet validates one optional colour override: a nil set passes (no
// override); otherwise all three colours must be present and "#rrggbb". It
// returns a trimmed, lower-cased copy.
func validColorSet(cs *ColorSet) (*ColorSet, error) {
if cs == nil {
return nil, nil
}
bg := strings.TrimSpace(cs.Bg)
fg := strings.TrimSpace(cs.Fg)
link := strings.TrimSpace(cs.Link)
if bg == "" || fg == "" || link == "" {
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
}
for _, h := range []string{bg, fg, link} {
if !hexColor.MatchString(h) {
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
}
}
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
}
// validBodies trims and bounds both mandatory language bodies of a message.
func validBodies(bodyEn, bodyRu string) (string, string, error) {
en := strings.TrimSpace(bodyEn)
+11 -49
View File
@@ -90,23 +90,15 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
id := uuid.New()
now := time.Now().UTC()
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.INSERT(
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent,
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
).VALUES(
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
postgres.Bool(false), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent),
postgres.TimestampzT(now), postgres.TimestampzT(now),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
@@ -119,20 +111,12 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
// window. The default flag is never touched here. Returns ErrNotFound when no
// campaign matches.
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.UPDATE(
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
).SET(
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
return execOne(ctx, s.db, stmt, "update campaign")
}
@@ -273,40 +257,18 @@ func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, wh
func modelToCampaign(r model.AdCampaigns) Campaign {
return Campaign{
ID: r.CampaignID,
Name: r.Name,
Weight: int(r.Weight),
IsDefault: r.IsDefault,
Enabled: r.Enabled,
StartsAt: r.StartsAt,
EndsAt: r.EndsAt,
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
Urgent: r.Urgent,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
ID: r.CampaignID,
Name: r.Name,
Weight: int(r.Weight),
IsDefault: r.IsDefault,
Enabled: r.Enabled,
StartsAt: r.StartsAt,
EndsAt: r.EndsAt,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
// the set is absent.
func colorSetFrom(bg, fg, link *string) *ColorSet {
if bg == nil || fg == nil || link == nil {
return nil
}
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
}
// colorCols renders a *ColorSet as its three column value-expressions in
// bg, fg, link order — NULL for each when the set is absent.
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
if cs == nil {
return postgres.NULL, postgres.NULL, postgres.NULL
}
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
}
func modelToMessage(r model.AdMessages) Message {
return Message{
ID: r.MessageID,
+5 -32
View File
@@ -4,7 +4,6 @@ package config
import (
"fmt"
"net/url"
"os"
"strconv"
"time"
@@ -43,12 +42,6 @@ type Config struct {
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
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
// side-service, used by the admin console to send operator broadcasts. Empty
// disables broadcasts (the admin broadcast actions report "not configured").
@@ -58,12 +51,6 @@ type Config struct {
// GuestRetention is the account age past which an unused guest (no game seat)
// is eligible for deletion by the reaper.
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.
@@ -143,14 +130,11 @@ func Load() (Config, error) {
}
smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
TLS: os.Getenv("BACKEND_SMTP_TLS"),
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
}
c := Config{
@@ -164,12 +148,9 @@ func Load() (Config, error) {
Robot: rb,
RateWatch: rw,
SMTP: smtp,
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
GuestReapInterval: guestReapInterval,
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -214,14 +195,6 @@ func (c Config) validate() error {
if c.GuestRetention <= 0 {
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
}
-5
View File
@@ -195,11 +195,6 @@ func (svc *Service) CountUnread(ctx context.Context) (int, error) {
return svc.store.CountUnread(ctx)
}
// CountSince counts feedback created after since, for the operator alert worker.
func (svc *Service) CountSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountSince(ctx, since)
}
// Attachment returns a message's file name and bytes, reporting false when absent.
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
return svc.store.Attachment(ctx, id)
-12
View File
@@ -386,15 +386,3 @@ func (s *Store) CountUnread(ctx context.Context) (int, error) {
}
return n, nil
}
// CountSince counts feedback messages created strictly after since — the operator alert
// worker's "new since the last check" signal.
func (s *Store) CountSince(ctx context.Context, since time.Time) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM backend.feedback_messages WHERE created_at > $1`, since,
).Scan(&n); err != nil {
return 0, fmt.Errorf("feedback: count since: %w", err)
}
return n, nil
}
+8 -37
View File
@@ -1008,12 +1008,6 @@ func (svc *Service) CountComplaints(ctx context.Context, status string) (int, er
return svc.store.CountComplaints(ctx, status)
}
// CountComplaintsSince counts word complaints filed after since, for the operator alert
// worker.
func (svc *Service) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountComplaintsSince(ctx, since)
}
// ResolveComplaint closes a complaint with an operator disposition (reject /
// accept_add / accept_remove) and an optional note. An accepted complaint then
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
@@ -1369,53 +1363,30 @@ func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDr
return svc.store.SetupDraws(ctx, gameID)
}
// ExportView returns a finished game with its journal and per-seat display names —
// the material every export artifact (the GCG text, the PNG render payload) is built
// from. It is allowed only on a finished game: exporting an in-progress game would
// 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) {
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
// is allowed only on a finished game: exporting an in-progress game would leak the
// full move journal mid-play, so an active game yields ErrGameActive.
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Game{}, nil, nil, err
return "", err
}
if g.Status != StatusFinished {
return Game{}, nil, nil, ErrGameActive
return "", ErrGameActive
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return Game{}, nil, nil, err
return "", err
}
names := svc.seatNames(ctx, g)
if g.VsAI {
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
for _, s := range g.Seats {
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
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
}
-13
View File
@@ -1040,19 +1040,6 @@ func (s *Store) CountComplaints(ctx context.Context, status string) (int, error)
return int(dest.Count), nil
}
// CountComplaintsSince counts word complaints filed strictly after since — the operator
// alert worker's "new since the last check" signal.
func (s *Store) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
FROM(table.Complaints).
WHERE(table.Complaints.CreatedAt.GT(postgres.TimestampzT(since)))
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count complaints since: %w", err)
}
return int(dest.Count), nil
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
@@ -1,83 +0,0 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// TestRemoveEmailIdentity erases an account's email identity but refuses when the
// email is the account's only identity (which would leave it unreachable).
func TestRemoveEmailIdentity(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
// Email is the only identity → refuse.
solo, err := store.ProvisionEmail(ctx, "solo-"+uuid.NewString()+"@example.com", "", "en")
if err != nil {
t.Fatalf("provision email: %v", err)
}
if err := store.RemoveEmailIdentity(ctx, solo.ID); !errors.Is(err, account.ErrLastIdentity) {
t.Fatalf("remove last identity = %v, want ErrLastIdentity", err)
}
// Telegram + email → erase the email, keep Telegram.
tg, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if err := store.AttachIdentity(ctx, tg.ID, account.KindEmail, "dual-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := store.RemoveEmailIdentity(ctx, tg.ID); err != nil {
t.Fatalf("remove email: %v", err)
}
ids, err := store.Identities(ctx, tg.ID)
if err != nil {
t.Fatalf("identities: %v", err)
}
if len(ids) != 1 || ids[0].Kind != account.KindTelegram {
t.Errorf("identities after erase = %+v, want only telegram", ids)
}
}
// TestListUsersEmailExact matches accounts strictly (exactly) by their email identity.
func TestListUsersEmailExact(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
email := "find-" + uuid.NewString() + "@example.com"
acc, err := store.ProvisionEmail(ctx, email, "", "en")
if err != nil {
t.Fatalf("provision: %v", err)
}
items, err := store.ListUsers(ctx, account.UserFilter{EmailExact: email}, 50, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
found := false
for _, it := range items {
if it.ID == acc.ID {
found = true
}
}
if !found {
t.Error("the exact email filter did not find the account")
}
other, err := store.ListUsers(ctx, account.UserFilter{EmailExact: "nope-" + uuid.NewString() + "@example.com"}, 50, 0)
if err != nil {
t.Fatalf("list (no match): %v", err)
}
for _, it := range other {
if it.ID == acc.ID {
t.Error("a non-matching email filter must not return the account")
}
}
}
+2 -142
View File
@@ -192,9 +192,8 @@ func TestBannerMessageOwnership(t *testing.T) {
type profileBanner struct {
Banner *struct {
Campaigns []struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
OverrideBg string `json:"override_bg"`
Weight int `json:"weight"`
Messages []string `json:"messages"`
} `json:"campaigns"`
Timings struct {
HoldMs int `json:"hold_ms"`
@@ -275,142 +274,3 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
}
}
// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over
// HTTP against real Postgres: an incomplete or malformed colour set is refused, a
// full two-set override + urgent round-trips through the store (exercising the new
// columns and CHECK constraints), clearing the sets removes the override, and the
// default campaign stays plain (colours + urgent are ignored for it).
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
h := srv.Handler()
base := "http://admin.test/_gm"
const origin = "http://admin.test"
name := "Brand-" + uuid.NewString()[:8]
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
}
id := findCampaign(t, adsSvc, name)
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) })
detail := base + "/banners/" + id.String()
// A partial colour set (a missing colour) is refused by validation.
partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00"
if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
}
// A malformed hex is refused.
badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00"
if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
}
// Both colour sets + urgent persist and round-trip through the store.
full := "name=" + name + "&weight=20&enabled=on&urgent=on" +
"&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" +
"&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00"
if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") {
t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved"))
}
cm, err := adsSvc.Campaign(ctx, id)
if err != nil {
t.Fatalf("read campaign: %v", err)
}
if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" {
t.Fatalf("override all = %+v", cm.OverrideAll)
}
if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" {
t.Fatalf("override dark = %+v", cm.OverrideDark)
}
if !cm.Urgent {
t.Fatal("urgent not persisted")
}
// Clearing the sets (both checkboxes off, urgent off) removes the override.
if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK {
t.Fatalf("clear = %d", code)
}
if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent {
t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent)
}
// The default campaign stays plain: submitted colours + urgent are ignored for it.
def := defaultCampaign(t, adsSvc)
defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00"
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK {
t.Fatalf("update default = %d", code)
}
if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent {
t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent)
}
}
// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level:
// an urgent campaign preempts the feed (only it shows, with its colours) and reaches
// every viewer — including the no_banner role and a non-empty hint wallet that
// otherwise suppress the banner.
func TestBannerUrgentBypassesEligibility(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
accounts := account.NewStore(testDB)
id := provisionAccount(t)
urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{
Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true,
OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"},
})
if err != nil {
t.Fatalf("create urgent: %v", err)
}
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) })
if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil {
t.Fatalf("add urgent message: %v", err)
}
get := func() profileBanner {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode: %v", err)
}
return p
}
assertUrgentOnly := func(what string, p profileBanner) {
t.Helper()
if p.Banner == nil {
t.Fatalf("%s: no banner", what)
}
if len(p.Banner.Campaigns) != 1 {
t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns))
}
c := p.Banner.Campaigns[0]
if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" {
t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages)
}
if c.OverrideBg != "#aa0000" {
t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg)
}
}
// A normal viewer sees only the urgent campaign (the default is preempted).
assertUrgentOnly("eligible", get())
// The no_banner role no longer suppresses it — urgent reaches everyone.
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("grant role: %v", err)
}
assertUrgentOnly("no_banner", get())
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
// A non-empty hint wallet no longer suppresses it either.
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
t.Fatalf("grant hints: %v", err)
}
assertUrgentOnly("hints", get())
}
-322
View File
@@ -1,322 +0,0 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountdelete"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// deletedFields reads a tombstoned account's retained real name and its deleted_at.
func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) {
t.Helper()
var dn sql.NullString
err := testDB.QueryRowContext(context.Background(),
"SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID).
Scan(&dn, &deletedAt)
if err != nil {
t.Fatalf("read deleted fields %s: %v", accountID, err)
}
return dn.String, deletedAt
}
// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the
// account, scrubs the live name while retaining the real one, and frees the creds for a
// new account to reuse.
func TestAnonymizeAndTombstone(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
if err != nil {
t.Fatalf("provision: %v", err)
}
email := "del-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("attach email: %v", err)
}
before, err := store.GetByID(ctx, acc.ID)
if err != nil {
t.Fatalf("load before: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
// The live identities are gone.
if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 {
t.Fatalf("identities after delete = %+v (err %v), want none", ids, err)
}
// Both credentials are journalled with reason=delete.
got := retainedRows(t, acc.ID)
if len(got) != 2 {
t.Fatalf("retained rows = %+v, want 2", got)
}
for _, r := range got {
if r.reason != "delete" {
t.Errorf("retained reason = %q, want delete", r.reason)
}
}
// The live name is scrubbed; the real one is retained; deleted_at is set.
after, err := store.GetByID(ctx, acc.ID)
if err != nil {
t.Fatalf("load after: %v", err)
}
if after.DisplayName != accountdelete.AnonymizedName {
t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName)
}
name, deletedAt := deletedFields(t, acc.ID)
if name != before.DisplayName {
t.Errorf("retained name = %q, want %q", name, before.DisplayName)
}
if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute {
t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt)
}
// The credentials are free: a new account can claim the same email.
other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "")
if err != nil {
t.Fatalf("provision other: %v", err)
}
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("email should be free after deletion, got: %v", err)
}
}
// TestDeletionDossierReaders: after deletion the admin readers expose the credential
// journal and the tombstone dossier.
func TestDeletionDossierReaders(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
rets, err := store.RetainedIdentities(ctx, acc.ID)
if err != nil || len(rets) != 2 {
t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err)
}
for _, r := range rets {
if r.Reason != "delete" {
t.Errorf("retained reason = %q, want delete", r.Reason)
}
}
info, err := store.DeletionInfo(ctx, acc.ID)
if err != nil {
t.Fatalf("DeletionInfo: %v", err)
}
if info.DeletedAt == nil {
t.Error("DeletionInfo.DeletedAt should be set")
}
if info.DeletedDisplayName != "Иван" {
t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName)
}
}
// listHasID reports whether the user list contains accountID.
func listHasID(items []account.UserListItem, id uuid.UUID) bool {
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}
// TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and
// shown only under the Deleted scope.
func TestUserListDeletedFilter(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "")
if err != nil {
t.Fatalf("provision live: %v", err)
}
goneTg := "tg-" + uuid.NewString()
gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "")
if err != nil {
t.Fatalf("provision gone: %v", err)
}
goneEmail := "gone-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil {
t.Fatalf("attach gone email: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil {
t.Fatalf("delete: %v", err)
}
people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0)
if err != nil {
t.Fatalf("list people: %v", err)
}
if listHasID(people, gone.ID) {
t.Error("a deleted account must not appear in the default People list")
}
if !listHasID(people, live.ID) {
t.Error("a live account must appear in the default People list")
}
deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0)
if err != nil {
t.Fatalf("list deleted: %v", err)
}
if !listHasID(deleted, gone.ID) {
t.Error("a deleted account must appear in the Deleted list")
}
if listHasID(deleted, live.ID) {
t.Error("a live account must not appear in the Deleted list")
}
// A search spans both lists and reaches the retention journal: a deleted account is
// still found by the email and external id it held (both moved to retained_identities
// on deletion, out of the live identities table).
byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0)
if err != nil {
t.Fatalf("search by email: %v", err)
}
if !listHasID(byEmail, gone.ID) {
t.Error("a deleted account must be found by the email it held (retention journal)")
}
byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0)
if err != nil {
t.Fatalf("search by external id: %v", err)
}
if !listHasID(byExt, gone.ID) {
t.Error("a deleted account must be found by the external id it held (retention journal)")
}
}
// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to
// a durable account (defence-in-depth — no confirmed-email path leaves is_guest set).
func TestConfirmCodeClearsGuest(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
email := "cc-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, guest.ID, email); err != nil {
t.Fatalf("request code: %v", err)
}
if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("confirm code: %v", err)
}
after, err := store.GetByID(ctx, guest.ID)
if err != nil {
t.Fatalf("load: %v", err)
}
if after.IsGuest {
t.Error("confirming an email must clear the guest flag")
}
}
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
// opponent.
func TestDropAllRobotGames(t *testing.T) {
ctx := context.Background()
gsvc := newGameService()
robots := newRobotService(t, gsvc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
mm := newMatchmaker(t, robots, time.Minute, 0)
deleter := accountdelete.NewDeleter(testDB)
user := provisionAccount(t)
other := provisionAccount(t)
aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("start vs AI: %v", err)
}
humanGame, err := gsvc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1,
})
if err != nil {
t.Fatalf("create human game: %v", err)
}
n, err := deleter.DropAllRobotGames(ctx, user)
if err != nil {
t.Fatalf("drop: %v", err)
}
if n != 1 {
t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n)
}
if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil {
t.Error("the vs-AI game should be dropped")
}
if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil {
t.Errorf("the human game should be kept, got: %v", err)
}
}
// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no
// deeplink in the mail); a platform-only account has no email and cannot request a code.
func TestDeleteStepUpEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "del-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if has, err := svc.HasEmail(ctx, acc.ID); err != nil || !has {
t.Fatalf("HasEmail = (%v, %v), want true", has, err)
}
if err := svc.RequestDeleteCode(ctx, acc.ID); err != nil {
t.Fatalf("request delete code: %v", err)
}
if strings.Contains(mailer.lastBody, "/confirm/") {
t.Error("a delete email must not carry a one-tap deeplink")
}
code := sixDigit.FindString(mailer.lastBody)
if err := svc.VerifyDeleteCode(ctx, acc.ID, "000000"); err == nil {
t.Error("a wrong delete code must be rejected")
}
if err := svc.VerifyDeleteCode(ctx, acc.ID, code); err != nil {
t.Fatalf("verify correct delete code: %v", err)
}
noEmail, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision no-email: %v", err)
}
if has, _ := svc.HasEmail(ctx, noEmail.ID); has {
t.Error("HasEmail must be false for a platform-only account")
}
if err := svc.RequestDeleteCode(ctx, noEmail.ID); !errors.Is(err, account.ErrNoEmail) {
t.Errorf("request delete for no-email account = %v, want ErrNoEmail", err)
}
}
+9 -188
View File
@@ -19,8 +19,8 @@ import (
// recover the confirm-code from the body.
type capturingMailer struct{ lastBody string }
func (m *capturingMailer) Send(_ context.Context, msg account.Message) error {
m.lastBody = msg.Text
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error {
m.lastBody = body
return nil
}
@@ -32,7 +32,7 @@ func TestEmailConfirmFlow(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "user-" + uuid.NewString() + "@example.com"
@@ -67,7 +67,7 @@ func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
svc := account.NewEmailService(store, mailer)
owner := provisionAccount(t)
email := "taken-" + uuid.NewString() + "@example.com"
@@ -89,7 +89,7 @@ func TestEmailCodeExpires(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "expire-" + uuid.NewString() + "@example.com"
@@ -111,7 +111,7 @@ func TestEmailTooManyAttempts(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "lock-" + uuid.NewString() + "@example.com"
@@ -203,10 +203,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
func TestEmailLoginFlow(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
svc := account.NewEmailService(account.NewStore(testDB), mailer)
email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00")
if err != nil {
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.
if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil {
t.Fatalf("second request: %v", err)
}
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
@@ -244,182 +244,3 @@ func TestEmailLoginFlow(t *testing.T) {
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)")
}
}
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
// the branded email carries.
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
func tokenFromMail(t *testing.T, body string) string {
t.Helper()
m := confirmToken.FindStringSubmatch(body)
if m == nil {
t.Fatalf("no confirm token in mail body %q", body)
}
return m[1]
}
// TestConfirmByTokenLogin: the one-tap deeplink token completes an email login,
// clearing the guest flag.
func TestConfirmByTokenLogin(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-login-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en")
if err != nil {
t.Fatalf("request login: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if !res.IsLogin() || res.Account != id {
t.Fatalf("login result = %+v, want login for %s", res, id)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("email identity must be confirmed after the token login")
}
if acc, _ := store.GetByID(ctx, id); acc.IsGuest {
t.Error("the token login must clear the guest flag")
}
}
// TestConfirmByTokenLink: the token attaches a free email to the requesting account.
func TestConfirmByTokenLink(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t)
email := "tok-link-" + uuid.NewString() + "@example.com"
if err := svc.RequestLinkCode(ctx, acc, email); err != nil {
t.Fatalf("request link: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if res.IsLogin() || res.NeedsMerge || res.Account != acc {
t.Fatalf("link result = %+v, want a plain link for %s", res, acc)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("email identity must be confirmed after the token link")
}
}
// TestConfirmByTokenLinkMerge: a token for an address owned by another account signals
// a merge (leaving the token unconsumed for the interactive step).
func TestConfirmByTokenLinkMerge(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-merge-" + uuid.NewString() + "@example.com"
owner := provisionAccount(t)
if err := svc.RequestCode(ctx, owner, email); err != nil {
t.Fatalf("owner request: %v", err)
}
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("owner confirm: %v", err)
}
other := provisionAccount(t)
if err := svc.RequestLinkCode(ctx, other, email); err != nil {
t.Fatalf("link request: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if !res.NeedsMerge || res.MergeOwner != owner {
t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner)
}
}
// TestEmailAccountSeedsDisplayName seeds a new email account's display name from the
// email's local part, so an email login is not left nameless.
func TestEmailAccountSeedsDisplayName(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
local := "kaya-" + uuid.NewString()[:8]
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en")
if err != nil {
t.Fatalf("request login: %v", err)
}
acc, err := store.GetByID(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if acc.DisplayName != local {
t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local)
}
}
// TestConfirmByTokenLinkClearsGuest: binding an email to a guest via the deeplink
// promotes the guest to a durable account (the deeplink path must match the
// code-based link flow, which clears the guest flag).
func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
email := "guest-link-" + uuid.NewString() + "@example.com"
if err := svc.RequestLinkCode(ctx, guest.ID, email); err != nil {
t.Fatalf("request link: %v", err)
}
if _, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)); err != nil {
t.Fatalf("confirm by token: %v", err)
}
acc, err := store.GetByID(ctx, guest.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if acc.IsGuest {
t.Error("linking an email via the deeplink must promote the guest to durable")
}
}
@@ -1,174 +0,0 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// emailOf returns the external id of the account's email identity, or "" when it has none.
func emailOf(t *testing.T, store *account.Store, id uuid.UUID) string {
t.Helper()
ids, err := store.Identities(context.Background(), id)
if err != nil {
t.Fatalf("identities %s: %v", id, err)
}
for _, i := range ids {
if i.Kind == account.KindEmail {
return i.ExternalID
}
}
return ""
}
// TestUnlinkProviderKeepsOthers detaches one provider from a multi-identity account and
// refuses to remove the last remaining identity.
func TestUnlinkProviderKeepsOthers(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
email := "unlink-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("attach email: %v", err)
}
// Removing a kind the account does not hold reports not-found.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindVK); !errors.Is(err, account.ErrNotFound) {
t.Fatalf("remove absent vk = %v, want ErrNotFound", err)
}
// Detach Telegram: the email identity remains.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
t.Fatalf("remove telegram: %v", err)
}
ids, err := store.Identities(ctx, acc.ID)
if err != nil {
t.Fatalf("identities: %v", err)
}
if len(ids) != 1 || ids[0].Kind != account.KindEmail {
t.Fatalf("identities after unlink = %+v, want only email", ids)
}
// The email is now the last identity, so unlinking it is refused.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrLastIdentity) {
t.Fatalf("remove last email = %v, want ErrLastIdentity", err)
}
}
// TestChangeEmailReplaces switches an account's confirmed email to a free address,
// freeing the old one.
func TestChangeEmailReplaces(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
oldAddr := "old-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "new-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, code); err != nil {
t.Fatalf("confirm change: %v", err)
}
if got := emailOf(t, store, acc.ID); got != newAddr {
t.Fatalf("email after change = %q, want %q", got, newAddr)
}
if !identityConfirmed(t, account.KindEmail, newAddr) {
t.Error("the new email identity must be confirmed")
}
// The old address is freed: another account can now claim it.
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision other: %v", err)
}
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("old address should be free after change, got: %v", err)
}
}
// TestChangeEmailRefusesTaken refuses (without merging) a new address already confirmed by
// another account, leaving the caller's email untouched.
func TestChangeEmailRefusesTaken(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision caller: %v", err)
}
mine := "mine-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, mine, true); err != nil {
t.Fatalf("attach caller email: %v", err)
}
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision other: %v", err)
}
taken := "taken-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, taken, true); err != nil {
t.Fatalf("attach other email: %v", err)
}
if err := svc.RequestChangeCode(ctx, acc.ID, taken); err != nil {
t.Fatalf("request change: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := svc.ConfirmChange(ctx, acc.ID, taken, code); !errors.Is(err, account.ErrEmailTaken) {
t.Fatalf("confirm change to taken = %v, want ErrEmailTaken", err)
}
if got := emailOf(t, store, acc.ID); got != mine {
t.Errorf("caller email after refused change = %q, want unchanged %q", got, mine)
}
}
// TestChangeEmailViaDeeplink switches the email through the one-tap confirm token.
func TestChangeEmailViaDeeplink(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "old-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "dl-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if res.IsLogin() || res.NeedsMerge || res.Account != acc.ID {
t.Fatalf("deeplink change result = %+v, want a plain change for %s", res, acc.ID)
}
if got := emailOf(t, store, acc.ID); got != newAddr {
t.Fatalf("email after deeplink change = %q, want %q", got, newAddr)
}
}
+1 -100
View File
@@ -119,7 +119,7 @@ func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID
func newLinkService(mailer account.Mailer) *link.Service {
store := account.NewStore(testDB)
emails := account.NewEmailService(store, mailer, "https://erudit-game.ru")
emails := account.NewEmailService(store, mailer)
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
}
@@ -251,44 +251,6 @@ func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
}
}
// TestAccountMergeDedupesEmail keeps the primary's email when both accounts have one and
// journals the secondary's to the dossier (reason=merge), so the survivor never ends up
// with two email identities.
func TestAccountMergeDedupesEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
primaryEmail := "keep-" + uuid.NewString() + "@example.com"
secondaryEmail := "absorb-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, primary, primaryEmail)
bindEmailIdentity(t, secondary, secondaryEmail)
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
// The primary keeps its own email; the secondary's is gone from the live identities.
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, primaryEmail); !ok || owner != primary {
t.Errorf("primary email owner = %s ok=%v, want primary %s", owner, ok, primary)
}
if _, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, secondaryEmail); ok {
t.Error("the secondary's email must be removed (no duplicate email on the survivor)")
}
// The absorbed email stays in the legal dossier, tagged reason=merge.
var reason string
if err := testDB.QueryRowContext(ctx,
`SELECT reason FROM backend.retained_identities WHERE account_id=$1 AND kind='email' AND external_id=$2`,
secondary, secondaryEmail).Scan(&reason); err != nil {
t.Fatalf("retained email row: %v", err)
}
if reason != "merge" {
t.Errorf("retained reason = %q, want merge", reason)
}
}
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
func TestAccountLinkFreeEmail(t *testing.T) {
ctx := context.Background()
@@ -393,64 +355,3 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Errorf("email owner = %s, want durable", owner)
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
guest := provisionGuest(t)
vkID := "vk-" + uuid.NewString()
res, err := links.ConfirmVK(ctx, guest, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !res.Linked || res.MergeRequired {
t.Fatalf("confirm = %+v, want linked", res)
}
if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
t.Error("guest flag should clear once VK is linked")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
}
}
// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
// stays primary and keeps its session, and the VK identity repoints to the caller.
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
caller := provisionAccount(t)
other := provisionAccount(t)
vkID := "vk-" + uuid.NewString()
if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
t.Fatalf("seed vk on other: %v", err)
}
confirm, err := links.ConfirmVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !confirm.MergeRequired || confirm.SecondaryID != other {
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
}
merge, err := links.MergeVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("merge vk: %v", err)
}
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
}
if mergedInto(t, other) != caller {
t.Error("other should be tombstoned into caller")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
t.Errorf("vk owner = %s, want caller after merge", owner)
}
}
-192
View File
@@ -1,192 +0,0 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountdelete"
)
// lastLoginIP reads an account's stamped last-login IP ("" when unset).
func lastLoginIP(t *testing.T, accountID uuid.UUID) string {
t.Helper()
var ip sql.NullString
err := testDB.QueryRowContext(context.Background(),
"SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip)
if err != nil {
t.Fatalf("read last_login_ip %s: %v", accountID, err)
}
return ip.String
}
// retainedRow is one row of the retention journal, read directly for assertions.
type retainedRow struct {
kind, externalID, reason string
}
// retainedRows reads the retention journal for an account, oldest detach first.
func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow {
t.Helper()
rows, err := testDB.QueryContext(context.Background(),
"SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at",
accountID)
if err != nil {
t.Fatalf("query retained_identities %s: %v", accountID, err)
}
defer rows.Close()
var out []retainedRow
for rows.Next() {
var r retainedRow
if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil {
t.Fatalf("scan retained row: %v", err)
}
out = append(out, r)
}
return out
}
// TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention
// journal (reason=unlink) before the live identity is removed.
func TestUnlinkJournalsRetainedIdentity(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
tgExt := "tg-" + uuid.NewString()
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
if err != nil {
t.Fatalf("provision: %v", err)
}
email := "keep-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
t.Fatalf("unlink telegram: %v", err)
}
got := retainedRows(t, acc.ID)
if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" {
t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt)
}
}
// TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the
// retention journal (reason=change).
func TestChangeEmailJournalsOldAddress(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
oldAddr := "old-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "new-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("confirm change: %v", err)
}
got := retainedRows(t, acc.ID)
if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" {
t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr)
}
}
// TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within
// the hour is a no-op (throttled), so it costs at most one write per account per hour.
func TestStampLastLoginThrottles(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil {
t.Fatalf("first stamp: %v", err)
}
if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
t.Fatalf("first stamp ip = %q, want 1.2.3.4", got)
}
if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil {
t.Fatalf("second stamp: %v", err)
}
if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got)
}
}
// TestReapExpiredRetention: the reaper keeps journal rows newer than the cutoff and purges
// older ones, and drops a long-deleted account's feedback thread + dossier PII.
func TestReapExpiredRetention(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
// An unlinked provider leaves a journal row detached "now".
tgExt := "tg-" + uuid.NewString()
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "keep-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
t.Fatalf("unlink: %v", err)
}
// A cutoff before the detach keeps the row.
if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(-time.Hour)); err != nil {
t.Fatalf("reap (early cutoff): %v", err)
}
if got := retainedRows(t, acc.ID); len(got) != 1 {
t.Fatalf("journal after early-cutoff reap = %+v, want kept", got)
}
// A cutoff after the detach purges it.
if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("reap (late cutoff): %v", err)
}
if got := retainedRows(t, acc.ID); len(got) != 0 {
t.Fatalf("journal after late-cutoff reap = %+v, want purged", got)
}
// A deleted account past the cutoff loses its feedback thread and dossier PII.
del, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Иван", "")
if err != nil {
t.Fatalf("provision deletee: %v", err)
}
if _, err := testDB.ExecContext(ctx,
"INSERT INTO feedback_messages (message_id, account_id, body, channel) VALUES ($1, $2, 'hi', 'web')",
uuid.New(), del.ID); err != nil {
t.Fatalf("insert feedback: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, del.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, fb, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil || fb == 0 {
t.Fatalf("reap deleted = (fb %d, err %v), want fb>=1", fb, err)
}
if name, _ := deletedFields(t, del.ID); name != "" {
t.Errorf("deleted_display_name after reap = %q, want cleared", name)
}
var fbCount int
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM feedback_messages WHERE account_id = $1", del.ID).Scan(&fbCount); err != nil {
t.Fatalf("count feedback: %v", err)
}
if fbCount != 0 {
t.Errorf("feedback rows after reap = %d, want 0", fbCount)
}
}
-47
View File
@@ -133,53 +133,6 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
return s.accounts.ClearGuest(ctx, callerID)
}
// ConfirmVK attaches a gateway-validated VK identity to the caller (Linked) or
// reports that it belongs to another account (MergeRequired). The gateway has already
// completed the VK ID code exchange, so externalID is the trusted vk user id.
func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return ConfirmResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
// (subject to the guest-primary rule).
func (s *Service) MergeVK(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return MergeResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return MergeResult{}, err
}
return MergeResult{PrimaryID: callerID}, nil
}
if owner == callerID {
return MergeResult{PrimaryID: callerID}, nil
}
return s.merge(ctx, callerID, owner)
}
// attachVK links the identity to the caller and promotes a guest.
func (s *Service) attachVK(ctx context.Context, callerID uuid.UUID, externalID string) error {
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindVK, externalID, true); err != nil {
return err
}
return s.accounts.ClearGuest(ctx, callerID)
}
// merge decides the primary (the caller, unless it is a guest and the other is
// durable), runs the data merge, retires the secondary's sessions and mints a new
// session when the active account switches.
-7
View File
@@ -155,13 +155,6 @@ func Notification(userID uuid.UUID, kind string) Intent {
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
}
// ProfileChanged is a payload-free "re-fetch your profile" signal to userID, emitted
// when the viewer's own account changed out of band — an email confirmed through the
// one-tap deeplink opened in another browser.
func ProfileChanged(userID uuid.UUID) Intent {
return Notification(userID, NotifyProfile)
}
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
// account it concerns (the requester, the new friend or the decliner), so the client updates its
// requests/friends lists and the in-game "add friend" state without a refetch.
-4
View File
@@ -69,10 +69,6 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only.
NotifyBanner = "banner"
// NotifyProfile tells the client that the viewer's own profile changed out of band
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
// so it re-fetches profile.get. It carries no payload. In-app only.
NotifyProfile = "profile"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
@@ -32,8 +32,4 @@ type Accounts struct {
MergedAt *time.Time
FlaggedHighRateAt *time.Time
VariantPreferences pq.StringArray
LastLoginAt *time.Time
LastLoginIP *string
DeletedAt *time.Time
DeletedDisplayName *string
}
@@ -13,20 +13,13 @@ import (
)
type AdCampaigns struct {
CampaignID uuid.UUID `sql:"primary_key"`
Name string
Weight int32
IsDefault bool
Enabled bool
StartsAt *time.Time
EndsAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
OverrideBg *string
OverrideFg *string
OverrideLink *string
OverrideBgDark *string
OverrideFgDark *string
OverrideLinkDark *string
Urgent bool
CampaignID uuid.UUID `sql:"primary_key"`
Name string
Weight int32
IsDefault bool
Enabled bool
StartsAt *time.Time
EndsAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -21,6 +21,4 @@ type EmailConfirmations struct {
Attempts int16
ConsumedAt *time.Time
CreatedAt time.Time
Purpose string
LinkTokenHash *string
}
@@ -1,24 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type RetainedIdentities struct {
RetainedID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Kind string
ExternalID string
Confirmed bool
LinkedAt time.Time
DetachedAt time.Time
Reason string
}
@@ -35,10 +35,6 @@ type accountsTable struct {
MergedAt postgres.ColumnTimestampz
FlaggedHighRateAt postgres.ColumnTimestampz
VariantPreferences postgres.ColumnStringArray
LastLoginAt postgres.ColumnTimestampz
LastLoginIP postgres.ColumnString
DeletedAt postgres.ColumnTimestampz
DeletedDisplayName postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -98,12 +94,8 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
MergedAtColumn = postgres.TimestampzColumn("merged_at")
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
LastLoginAtColumn = postgres.TimestampzColumn("last_login_at")
LastLoginIPColumn = postgres.StringColumn("last_login_ip")
DeletedAtColumn = postgres.TimestampzColumn("deleted_at")
DeletedDisplayNameColumn = postgres.StringColumn("deleted_display_name")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn}
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
)
@@ -129,10 +121,6 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
MergedAt: MergedAtColumn,
FlaggedHighRateAt: FlaggedHighRateAtColumn,
VariantPreferences: VariantPreferencesColumn,
LastLoginAt: LastLoginAtColumn,
LastLoginIP: LastLoginIPColumn,
DeletedAt: DeletedAtColumn,
DeletedDisplayName: DeletedDisplayNameColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -17,22 +17,15 @@ type adCampaignsTable struct {
postgres.Table
// Columns
CampaignID postgres.ColumnString
Name postgres.ColumnString
Weight postgres.ColumnInteger
IsDefault postgres.ColumnBool
Enabled postgres.ColumnBool
StartsAt postgres.ColumnTimestampz
EndsAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
OverrideBg postgres.ColumnString
OverrideFg postgres.ColumnString
OverrideLink postgres.ColumnString
OverrideBgDark postgres.ColumnString
OverrideFgDark postgres.ColumnString
OverrideLinkDark postgres.ColumnString
Urgent postgres.ColumnBool
CampaignID postgres.ColumnString
Name postgres.ColumnString
Weight postgres.ColumnInteger
IsDefault postgres.ColumnBool
Enabled postgres.ColumnBool
StartsAt postgres.ColumnTimestampz
EndsAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -74,47 +67,33 @@ func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
var (
CampaignIDColumn = postgres.StringColumn("campaign_id")
NameColumn = postgres.StringColumn("name")
WeightColumn = postgres.IntegerColumn("weight")
IsDefaultColumn = postgres.BoolColumn("is_default")
EnabledColumn = postgres.BoolColumn("enabled")
StartsAtColumn = postgres.TimestampzColumn("starts_at")
EndsAtColumn = postgres.TimestampzColumn("ends_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
OverrideBgColumn = postgres.StringColumn("override_bg")
OverrideFgColumn = postgres.StringColumn("override_fg")
OverrideLinkColumn = postgres.StringColumn("override_link")
OverrideBgDarkColumn = postgres.StringColumn("override_bg_dark")
OverrideFgDarkColumn = postgres.StringColumn("override_fg_dark")
OverrideLinkDarkColumn = postgres.StringColumn("override_link_dark")
UrgentColumn = postgres.BoolColumn("urgent")
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn, UrgentColumn}
CampaignIDColumn = postgres.StringColumn("campaign_id")
NameColumn = postgres.StringColumn("name")
WeightColumn = postgres.IntegerColumn("weight")
IsDefaultColumn = postgres.BoolColumn("is_default")
EnabledColumn = postgres.BoolColumn("enabled")
StartsAtColumn = postgres.TimestampzColumn("starts_at")
EndsAtColumn = postgres.TimestampzColumn("ends_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn}
)
return adCampaignsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
CampaignID: CampaignIDColumn,
Name: NameColumn,
Weight: WeightColumn,
IsDefault: IsDefaultColumn,
Enabled: EnabledColumn,
StartsAt: StartsAtColumn,
EndsAt: EndsAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
OverrideBg: OverrideBgColumn,
OverrideFg: OverrideFgColumn,
OverrideLink: OverrideLinkColumn,
OverrideBgDark: OverrideBgDarkColumn,
OverrideFgDark: OverrideFgDarkColumn,
OverrideLinkDark: OverrideLinkDarkColumn,
Urgent: UrgentColumn,
CampaignID: CampaignIDColumn,
Name: NameColumn,
Weight: WeightColumn,
IsDefault: IsDefaultColumn,
Enabled: EnabledColumn,
StartsAt: StartsAtColumn,
EndsAt: EndsAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -25,8 +25,6 @@ type emailConfirmationsTable struct {
Attempts postgres.ColumnInteger
ConsumedAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
Purpose postgres.ColumnString
LinkTokenHash postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -76,11 +74,9 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
AttemptsColumn = postgres.IntegerColumn("attempts")
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
PurposeColumn = postgres.StringColumn("purpose")
LinkTokenHashColumn = postgres.StringColumn("link_token_hash")
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn, PurposeColumn}
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
)
return emailConfirmationsTable{
@@ -95,8 +91,6 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
Attempts: AttemptsColumn,
ConsumedAt: ConsumedAtColumn,
CreatedAt: CreatedAtColumn,
Purpose: PurposeColumn,
LinkTokenHash: LinkTokenHashColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -1,99 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var RetainedIdentities = newRetainedIdentitiesTable("backend", "retained_identities", "")
type retainedIdentitiesTable struct {
postgres.Table
// Columns
RetainedID postgres.ColumnString
AccountID postgres.ColumnString
Kind postgres.ColumnString
ExternalID postgres.ColumnString
Confirmed postgres.ColumnBool
LinkedAt postgres.ColumnTimestampz
DetachedAt postgres.ColumnTimestampz
Reason postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type RetainedIdentitiesTable struct {
retainedIdentitiesTable
EXCLUDED retainedIdentitiesTable
}
// AS creates new RetainedIdentitiesTable with assigned alias
func (a RetainedIdentitiesTable) AS(alias string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new RetainedIdentitiesTable with assigned schema name
func (a RetainedIdentitiesTable) FromSchema(schemaName string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new RetainedIdentitiesTable with assigned table prefix
func (a RetainedIdentitiesTable) WithPrefix(prefix string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new RetainedIdentitiesTable with assigned table suffix
func (a RetainedIdentitiesTable) WithSuffix(suffix string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newRetainedIdentitiesTable(schemaName, tableName, alias string) *RetainedIdentitiesTable {
return &RetainedIdentitiesTable{
retainedIdentitiesTable: newRetainedIdentitiesTableImpl(schemaName, tableName, alias),
EXCLUDED: newRetainedIdentitiesTableImpl("", "excluded", ""),
}
}
func newRetainedIdentitiesTableImpl(schemaName, tableName, alias string) retainedIdentitiesTable {
var (
RetainedIDColumn = postgres.StringColumn("retained_id")
AccountIDColumn = postgres.StringColumn("account_id")
KindColumn = postgres.StringColumn("kind")
ExternalIDColumn = postgres.StringColumn("external_id")
ConfirmedColumn = postgres.BoolColumn("confirmed")
LinkedAtColumn = postgres.TimestampzColumn("linked_at")
DetachedAtColumn = postgres.TimestampzColumn("detached_at")
ReasonColumn = postgres.StringColumn("reason")
allColumns = postgres.ColumnList{RetainedIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn}
defaultColumns = postgres.ColumnList{ConfirmedColumn, DetachedAtColumn}
)
return retainedIdentitiesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
RetainedID: RetainedIDColumn,
AccountID: AccountIDColumn,
Kind: KindColumn,
ExternalID: ExternalIDColumn,
Confirmed: ConfirmedColumn,
LinkedAt: LinkedAtColumn,
DetachedAt: DetachedAtColumn,
Reason: ReasonColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -34,7 +34,6 @@ func UseSchema(schema string) {
GameSetupDraws = GameSetupDraws.FromSchema(schema)
Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema)
RetainedIdentities = RetainedIdentities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema)
SuspensionReasons = SuspensionReasons.FromSchema(schema)
}
@@ -1,27 +0,0 @@
-- Add the confirm deeplink token and the confirmation purpose to email_confirmations.
-- purpose tags what verifying the code/token does (email login, identity link, email
-- change, or account deletion); link_token_hash holds the SHA-256 hex of the one-click
-- deeplink token (only the hash is stored, mirroring the session-token model).
-- Expand-contract: both columns are additive. purpose gets a NOT NULL DEFAULT so
-- existing rows fill in; link_token_hash is nullable with a partial-unique index. A
-- backend image rollback stays DB-safe — older code simply ignores the new columns. The
-- table shape changes, so the generated go-jet model for this table is regenerated.
-- +goose Up
ALTER TABLE backend.email_confirmations
ADD COLUMN purpose text NOT NULL DEFAULT 'link',
ADD COLUMN link_token_hash text;
ALTER TABLE backend.email_confirmations
ADD CONSTRAINT email_confirmations_purpose_chk
CHECK ((purpose = ANY (ARRAY['login'::text, 'link'::text, 'change'::text, 'delete'::text])));
CREATE UNIQUE INDEX email_confirmations_link_token_hash_key
ON backend.email_confirmations (link_token_hash)
WHERE link_token_hash IS NOT NULL;
-- +goose Down
DROP INDEX IF EXISTS backend.email_confirmations_link_token_hash_key;
ALTER TABLE backend.email_confirmations
DROP CONSTRAINT IF EXISTS email_confirmations_purpose_chk;
ALTER TABLE backend.email_confirmations
DROP COLUMN IF EXISTS link_token_hash,
DROP COLUMN IF EXISTS purpose;
@@ -1,47 +0,0 @@
-- Account deletion as legal retention (not erasure). Two additive pieces.
--
-- retained_identities is an append-only journal of every credential detached from an
-- account — on unlink, email change, or account deletion (reason). It preserves the legal
-- dossier (which email/vk/tg was linked, and when) while the live identities row is
-- removed, so the (kind, external_id) frees for a new account to reuse. No unique
-- constraint and no kind CHECK: the same credential may recur across accounts and events,
-- and the log stays robust to future identity kinds. detached_at drives the retention TTL.
--
-- accounts gains: last_login_at / last_login_ip (stamped on a cold app-load, throttled),
-- deleted_at (the tombstone marker), and deleted_display_name (the real name retained for
-- the admin dossier after the live display_name is scrubbed to the anonymised label).
--
-- Expand-contract: everything is additive (a new table plus nullable columns), so a
-- backend image rollback stays DB-safe — older code simply ignores them. The accounts
-- table shape changes, so its generated go-jet model is regenerated.
-- +goose Up
CREATE TABLE backend.retained_identities (
retained_id uuid NOT NULL,
account_id uuid NOT NULL,
kind text NOT NULL,
external_id text NOT NULL,
confirmed boolean DEFAULT false NOT NULL,
linked_at timestamp with time zone NOT NULL,
detached_at timestamp with time zone DEFAULT now() NOT NULL,
reason text NOT NULL,
CONSTRAINT retained_identities_pkey PRIMARY KEY (retained_id),
CONSTRAINT retained_identities_reason_chk
CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text])))
);
CREATE INDEX retained_identities_account_id_idx ON backend.retained_identities (account_id);
CREATE INDEX retained_identities_detached_at_idx ON backend.retained_identities (detached_at);
ALTER TABLE backend.accounts
ADD COLUMN last_login_at timestamp with time zone,
ADD COLUMN last_login_ip text,
ADD COLUMN deleted_at timestamp with time zone,
ADD COLUMN deleted_display_name text;
-- +goose Down
ALTER TABLE backend.accounts
DROP COLUMN last_login_at,
DROP COLUMN last_login_ip,
DROP COLUMN deleted_at,
DROP COLUMN deleted_display_name;
DROP TABLE backend.retained_identities;
@@ -1,22 +0,0 @@
-- Admit the 'merge' reason into retained_identities. An account merge folds a secondary
-- account into a primary; when both hold an identity of the same kind (e.g. each has a
-- confirmed email), the survivor keeps its own and the secondary's is journaled to the
-- legal dossier and removed — so the survivor never ends up with two identities of one
-- kind, and the absorbed credential is still retained. That journal row carries reason
-- 'merge', alongside the existing unlink / change / delete.
--
-- Expand-contract: the Up only WIDENS the allowed reason set, so an older backend image
-- (which writes only unlink/change/delete) still satisfies the constraint — a rollback
-- stays DB-safe. The Down narrows it again and would reject pre-existing 'merge' rows, so
-- it is a dev-only convenience, not a production rollback path (image rollback runs old
-- code against this schema, not the Down migration).
-- +goose Up
ALTER TABLE backend.retained_identities DROP CONSTRAINT retained_identities_reason_chk;
ALTER TABLE backend.retained_identities ADD CONSTRAINT retained_identities_reason_chk
CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text, 'merge'::text])));
-- +goose Down
ALTER TABLE backend.retained_identities DROP CONSTRAINT retained_identities_reason_chk;
ALTER TABLE backend.retained_identities ADD CONSTRAINT retained_identities_reason_chk
CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text])));
@@ -1,60 +0,0 @@
-- Per-campaign banner colour overrides and the urgent (always-show) flag.
--
-- ad_campaigns gains two optional colour sets and an urgent flag, all for
-- non-default campaigns only:
-- override_bg/fg/link — paints the strip on every theme
-- override_bg/fg/link_dark — further overrides the dark theme
-- urgent — force the banner on every viewer (bypassing
-- eligibility) and suppress all non-urgent
-- campaigns while any urgent one is active
--
-- Each colour set is all-or-nothing (bg+fg+link together, or absent) and every
-- colour is a "#rrggbb" hex string. The default (house) campaign stays plain: no
-- colours, never urgent.
--
-- Expand-contract: additive (nullable columns plus one NOT NULL boolean with a
-- default), so a backend image rollback stays DB-safe — older code ignores them.
-- The ad_campaigns table shape changes, so its generated go-jet model is
-- regenerated (by hand here, to keep the diff to this one table).
-- +goose Up
ALTER TABLE backend.ad_campaigns
ADD COLUMN override_bg text,
ADD COLUMN override_fg text,
ADD COLUMN override_link text,
ADD COLUMN override_bg_dark text,
ADD COLUMN override_fg_dark text,
ADD COLUMN override_link_dark text,
ADD COLUMN urgent boolean DEFAULT false NOT NULL,
ADD CONSTRAINT ad_campaigns_override_all_chk CHECK (
(override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL)
OR (override_bg IS NOT NULL AND override_fg IS NOT NULL AND override_link IS NOT NULL)
),
ADD CONSTRAINT ad_campaigns_override_dark_chk CHECK (
(override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL)
OR (override_bg_dark IS NOT NULL AND override_fg_dark IS NOT NULL AND override_link_dark IS NOT NULL)
),
ADD CONSTRAINT ad_campaigns_override_hex_chk CHECK (
(override_bg IS NULL OR override_bg ~ '^#[0-9a-fA-F]{6}$')
AND (override_fg IS NULL OR override_fg ~ '^#[0-9a-fA-F]{6}$')
AND (override_link IS NULL OR override_link ~ '^#[0-9a-fA-F]{6}$')
AND (override_bg_dark IS NULL OR override_bg_dark ~ '^#[0-9a-fA-F]{6}$')
AND (override_fg_dark IS NULL OR override_fg_dark ~ '^#[0-9a-fA-F]{6}$')
AND (override_link_dark IS NULL OR override_link_dark ~ '^#[0-9a-fA-F]{6}$')
),
ADD CONSTRAINT ad_campaigns_default_plain_chk CHECK (
(NOT is_default)
OR (override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL
AND override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL
AND NOT urgent)
);
-- +goose Down
ALTER TABLE backend.ad_campaigns
DROP COLUMN override_bg,
DROP COLUMN override_fg,
DROP COLUMN override_link,
DROP COLUMN override_bg_dark,
DROP COLUMN override_fg_dark,
DROP COLUMN override_link_dark,
DROP COLUMN urgent;
-61
View File
@@ -1,61 +0,0 @@
// 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
}
+13 -67
View File
@@ -22,20 +22,10 @@ type bannerDTO struct {
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
// weight (for the client's smooth weighted round-robin) and its messages, in
// display order, already resolved to one language. The override_* colours are
// present only for a campaign that carries a colour override: override_bg/fg/link
// paint every theme, and the *_dark trio further overrides the dark theme (the
// client resolves the cascade). They are absent for a plain campaign, which keeps
// the neutral theme tokens.
// display order, already resolved to one language.
type bannerCampaignDTO struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
OverrideBg string `json:"override_bg,omitempty"`
OverrideFg string `json:"override_fg,omitempty"`
OverrideLink string `json:"override_link,omitempty"`
OverrideBgDark string `json:"override_bg_dark,omitempty"`
OverrideFgDark string `json:"override_fg_dark,omitempty"`
OverrideLinkDark string `json:"override_link_dark,omitempty"`
Weight int `json:"weight"`
Messages []string `json:"messages"`
}
// bannerTimingsDTO mirrors ads.Timings on the wire.
@@ -55,34 +45,9 @@ type bannerTimingsDTO struct {
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc)
r.Banner = s.bannerFor(ctx, acc)
s.fillLinkedIdentities(ctx, &r, acc.ID)
return r
}
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
// flags from the account's identities, so the client offers the right link / unlink /
// change-email controls. A read failure leaves them zero (no controls), logged as a
// warning so the profile response still succeeds.
func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, accountID uuid.UUID) {
ids, err := s.accounts.Identities(ctx, accountID)
if err != nil {
s.log.Warn("profile: identities read failed", zap.String("account", accountID.String()), zap.Error(err))
return
}
for _, id := range ids {
switch id.Kind {
case account.KindEmail:
if id.Confirmed {
r.Email = id.ExternalID
}
case account.KindTelegram:
r.TelegramLinked = true
case account.KindVK:
r.VkLinked = true
}
}
}
// bannerFor builds the advertising-banner block for the account's profile, or
// nil when the ads service is not configured or the viewer is not eligible to
// see a banner. The message language follows the account's bot (service)
@@ -93,27 +58,22 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
if s.ads == nil {
return nil
}
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
if err != nil {
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
return nil
}
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
if err != nil {
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
// An urgent campaign is shown to every viewer; otherwise the normal eligibility
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
// hides the banner).
if !urgent {
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
if err != nil {
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
return nil
}
}
campaigns := make([]bannerCampaignDTO, 0, len(set))
for _, c := range set {
campaigns = append(campaigns, bannerCampaignFromActive(c))
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
}
return &bannerDTO{
Campaigns: campaigns,
@@ -128,20 +88,6 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
}
}
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
// projecting each optional colour set into its three "#rrggbb" fields (empty when
// the set is absent, so JSON omitempty drops them).
func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO {
d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}
if c.OverrideAll != nil {
d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link
}
if c.OverrideDark != nil {
d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link
}
return d
}
// bannerLang resolves the message language for a viewer: their interface language
// (Russian, or English by default).
func bannerLang(acc account.Account) string {
-7
View File
@@ -56,13 +56,6 @@ type profileResponse struct {
// see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go.
Banner *bannerDTO `json:"banner,omitempty"`
// Email is the account's confirmed email address ("" when none); TelegramLinked and
// VkLinked report whether a platform identity is attached. They drive the profile's
// link / unlink / change-email controls, and are filled outside the pure projection
// (they read the account's identities). See Server.profileResponse.
Email string `json:"email"`
TelegramLinked bool `json:"telegram_linked"`
VkLinked bool `json:"vk_linked"`
}
// tileDTO is one placed (or to-place) tile.
-1
View File
@@ -30,7 +30,6 @@ func TestStatusForError(t *testing.T) {
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"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"},
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
-310
View File
@@ -1,310 +0,0 @@
// 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
}
-148
View File
@@ -1,148 +0,0 @@
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)
}
}
}
-22
View File
@@ -31,7 +31,6 @@ func (s *Server) registerRoutes() {
in.POST("/sessions/guest", s.handleGuestAuth)
in.POST("/sessions/email/request", s.handleEmailRequest)
in.POST("/sessions/email/login", s.handleEmailLogin)
in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession)
// Out-of-app push routing for the platform side-service: the
@@ -74,18 +73,6 @@ func (s *Server) registerRoutes() {
u.POST("/link/email/merge", s.handleLinkEmailMerge)
u.POST("/link/telegram", s.handleLinkTelegram)
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
u.POST("/link/vk", s.handleLinkVK)
u.POST("/link/vk/merge", s.handleLinkVKMerge)
u.POST("/link/unlink", s.handleUnlink)
// Change the account's confirmed email: mail a code to the new address, then
// atomically switch on confirm (a new address owned by another account is
// refused without disclosure, never merged).
u.POST("/link/email/change/request", s.handleChangeEmailRequest)
u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm)
// Account deletion (legal retention, not erasure): step-up via a mailed code
// (email accounts) or a typed phrase (platform-only), then tombstone + free creds.
u.POST("/delete/request", s.handleRequestDelete)
u.POST("/delete/confirm", s.handleConfirmDelete)
}
if s.games != nil {
u.GET("/games", s.handleListGames)
@@ -100,17 +87,12 @@ func (s *Server) registerRoutes() {
u.POST("/games/:id/complaint", s.handleComplaint)
u.GET("/games/:id/history", s.handleHistory)
u.GET("/games/:id/gcg", s.handleExportGCG)
u.GET("/games/:id/export-url", s.handleExportURL)
u.GET("/games/:id/draft", s.handleGetDraft)
u.PUT("/games/:id/draft", s.handleSaveDraft)
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 {
u.POST("/feedback", s.handleFeedbackSubmit)
@@ -243,8 +225,6 @@ func statusForError(err error) (int, string) {
return http.StatusUnprocessableEntity, "illegal_play"
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
return http.StatusConflict, "email_taken"
case errors.Is(err, account.ErrLastIdentity):
return http.StatusConflict, "last_identity"
case errors.Is(err, accountmerge.ErrActiveGameConflict):
return http.StatusConflict, "merge_active_game_conflict"
case errors.Is(err, account.ErrInvalidEmail):
@@ -252,8 +232,6 @@ func statusForError(err error) (int, string) {
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
return http.StatusUnauthorized, "code_invalid"
case errors.Is(err, account.ErrTooManyRequests):
return http.StatusTooManyRequests, "too_many_requests"
case errors.Is(err, session.ErrNotFound):
return http.StatusUnauthorized, "session_invalid"
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
@@ -19,15 +19,6 @@ const bannersBack = "/_gm/banners"
// bound; the operator's entry is interpreted as UTC.
const localTimeLayout = "2006-01-02T15:04"
// Neutral banner theme tokens, mirrored from ui/src/app.css (--ad-bg,
// --text-muted, --accent for the light and dark themes). They seed the console's
// colour pickers and the preview fallback when a campaign has no override, so the
// operator always edits against the real neutral colours.
const (
tokenLightBg, tokenLightFg, tokenLightLink = "#e3e7ee", "#6b7280", "#2f6df6"
tokenDarkBg, tokenDarkFg, tokenDarkLink = "#272f3c", "#9aa3b2", "#5b8cff"
)
// consoleBanners lists the advertising campaigns (default first), each with its
// weight, validity window, enabled flag, message count and live-now status.
func (s *Server) consoleBanners(c *gin.Context) {
@@ -65,19 +56,14 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
return
}
view := adminconsole.BannerDetailView{
ID: cm.ID.String(),
Name: cm.Name,
Weight: cm.Weight,
IsDefault: cm.IsDefault,
Enabled: cm.Enabled,
StartsAt: localInput(cm.StartsAt),
EndsAt: localInput(cm.EndsAt),
Urgent: cm.Urgent,
OverrideAllOn: cm.OverrideAll != nil,
OverrideDarkOn: cm.OverrideDark != nil,
ID: cm.ID.String(),
Name: cm.Name,
Weight: cm.Weight,
IsDefault: cm.IsDefault,
Enabled: cm.Enabled,
StartsAt: localInput(cm.StartsAt),
EndsAt: localInput(cm.EndsAt),
}
view.AllBg, view.AllFg, view.AllLink = seedColors(cm.OverrideAll, tokenLightBg, tokenLightFg, tokenLightLink)
view.DarkBg, view.DarkFg, view.DarkLink = seedColors(cm.OverrideDark, tokenDarkBg, tokenDarkFg, tokenDarkLink)
for i, m := range cm.Messages {
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
ID: m.ID.String(),
@@ -125,15 +111,12 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
return
}
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
ID: id,
Name: trimForm(c, "name"),
Weight: atoiForm(c, "weight"),
Enabled: c.PostForm("enabled") != "",
StartsAt: starts,
EndsAt: ends,
Urgent: c.PostForm("urgent") != "",
OverrideAll: colorSetForm(c, "override_all_on", "override_bg", "override_fg", "override_link"),
OverrideDark: colorSetForm(c, "override_dark_on", "override_bg_dark", "override_fg_dark", "override_link_dark"),
ID: id,
Name: trimForm(c, "name"),
Weight: atoiForm(c, "weight"),
Enabled: c.PostForm("enabled") != "",
StartsAt: starts,
EndsAt: ends,
})
if err != nil {
s.bannerError(c, err, back)
@@ -338,29 +321,3 @@ func atoiForm(c *gin.Context, name string) int {
n, _ := strconv.Atoi(trimForm(c, name))
return n
}
// seedColors returns the three colour-input seed values for a campaign's optional
// override: the stored colours when the set is present, otherwise the given
// neutral theme tokens (so the native picker starts sensibly and the preview
// shows the real fallback).
func seedColors(cs *ads.ColorSet, tokenBg, tokenFg, tokenLink string) (bg, fg, link string) {
if cs == nil {
return tokenBg, tokenFg, tokenLink
}
return cs.Bg, cs.Fg, cs.Link
}
// colorSetForm reads an optional colour override from the form: the on flag gates
// it (an unchecked set is absent, nil), otherwise the three posted colours are
// returned for the ads service to validate. The colour inputs are disabled in the
// browser while the set is off, so they are not posted then.
func colorSetForm(c *gin.Context, on, bg, fg, link string) *ads.ColorSet {
if c.PostForm(on) == "" {
return nil
}
return &ads.ColorSet{
Bg: trimForm(c, bg),
Fg: trimForm(c, fg),
Link: trimForm(c, link),
}
}
@@ -61,8 +61,6 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
gm.POST("/users/:id/delete", s.consoleDeleteUser)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
@@ -134,10 +132,8 @@ func (s *Server) consoleUsers(c *gin.Context) {
page := consolePage(c)
filter := account.UserFilter{
Robots: c.Query("kind") == "robots",
Deleted: c.Query("kind") == "deleted",
NameMask: c.Query("name"),
ExternalIDMask: c.Query("ext"),
EmailExact: c.Query("email"),
}
total, _ := s.accounts.CountUsers(ctx, filter)
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
@@ -149,38 +145,28 @@ func (s *Server) consoleUsers(c *gin.Context) {
if filter.Robots {
q.Set("kind", "robots")
}
if filter.Deleted {
q.Set("kind", "deleted")
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExternalIDMask) != "" {
q.Set("ext", filter.ExternalIDMask)
}
if strings.TrimSpace(filter.EmailExact) != "" {
q.Set("email", filter.EmailExact)
}
view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, Deleted: filter.Deleted, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
EmailExact: filter.EmailExact,
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
FilterQuery: template.URL(q.Encode()),
}
ids := make([]uuid.UUID, 0, len(items))
for _, it := range items {
kind := "registered"
switch {
case it.IsRobot:
if it.IsRobot {
kind = "robot"
case it.IsDeleted:
kind = "deleted"
case it.IsGuest:
} else if it.IsGuest {
kind = "guest"
}
view.Items = append(view.Items, adminconsole.UserRow{
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
Language: it.PreferredLanguage, Guest: it.IsGuest, Deleted: it.IsDeleted,
Language: it.PreferredLanguage, Guest: it.IsGuest,
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
})
ids = append(ids, it.ID)
@@ -370,31 +356,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
}
if ids, err := s.accounts.Identities(ctx, id); err == nil {
for _, idn := range ids {
if idn.Kind == account.KindEmail {
view.HasEmail = true
}
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
}
}
if info, err := s.accounts.DeletionInfo(ctx, id); err == nil {
if info.LastLoginAt != nil {
view.LastLoginAt = fmtTime(*info.LastLoginAt)
}
view.LastLoginIP = info.LastLoginIP
if info.DeletedAt != nil {
view.Deleted = true
view.DeletedAt = fmtTime(*info.DeletedAt)
view.DeletedName = info.DeletedDisplayName
}
}
if rets, err := s.accounts.RetainedIdentities(ctx, id); err == nil {
for _, r := range rets {
view.Retained = append(view.Retained, adminconsole.RetainedRow{
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
Confirmed: r.Confirmed, LinkedAt: fmtTime(r.LinkedAt), DetachedAt: fmtTime(r.DetachedAt),
})
}
}
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
view.TelegramID = tg
}
@@ -987,41 +951,6 @@ func (s *Server) consoleGrantHints(c *gin.Context) {
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
}
// consoleRemoveEmail deletes the account's bound email identity (and any pending
// confirmations), freeing the address. It refuses to remove the account's only
// identity, which would leave it unreachable.
func (s *Server) consoleRemoveEmail(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); {
case errors.Is(err, account.ErrLastIdentity):
s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back)
case err != nil:
s.consoleError(c, err)
default:
s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", back)
}
}
// consoleDeleteUser deletes an account from the console — the operator-initiated equivalent
// of the in-app deletion (legal retention, not erasure): the account is tombstoned, its
// credentials journalled + freed, its live surfaces anonymised, and its sessions revoked.
func (s *Server) consoleDeleteUser(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if err := s.deleteAccount(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "the account was deleted: its credentials were journalled and freed, its data anonymised, and its sessions revoked", back)
}
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
// games, removing them from matchmaking. The block takes effect on the player's next request.
+1 -53
View File
@@ -9,7 +9,6 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
@@ -173,7 +172,6 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
type emailRequest struct {
Email string `json:"email"`
BrowserTZ string `json:"browser_tz"`
Language string `json:"language"`
}
// handleEmailRequest issues a login confirm-code to the email. It always reports
@@ -185,7 +183,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
abortBadRequest(c, "email is required")
return
}
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil {
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil {
s.abortErr(c, err)
return
}
@@ -213,56 +211,6 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
s.mintSession(c, acc)
}
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
// Session carries the freshly minted credential (the deeplink page signs in with it);
// for a link, Status is "confirmed" or "merge_required" (the app completes the merge).
type confirmLinkResponse struct {
Purpose string `json:"purpose"`
Status string `json:"status"`
Session *sessionResponse `json:"session,omitempty"`
}
// handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session
// (the deeplink page signs in with it); a link attaches the confirmed email, reporting
// "merge_required" when the address is owned by another account so the app drives the
// interactive merge. The token, not a request session, is the authorization — the page
// need not be signed in.
func (s *Server) handleEmailConfirmLink(c *gin.Context) {
var req tokenRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
abortBadRequest(c, "token is required")
return
}
res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token)
if err != nil {
s.abortErr(c, err)
return
}
if res.IsLogin() {
acc, err := s.accounts.GetByID(c.Request.Context(), res.Account)
if err != nil {
s.abortErr(c, err)
return
}
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
if err != nil {
s.abortErr(c, err)
return
}
sess := sessionResponseFor(token, acc)
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
return
}
if res.NeedsMerge {
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "merge_required"})
return
}
// A free link attached the email; nudge the account's in-app session(s) to re-fetch
// the profile, so a link confirmed in another browser reflects at once.
s.notifier.Publish(notify.ProfileChanged(res.Account))
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "confirmed"})
}
// tokenRequest carries an opaque session token.
type tokenRequest struct {
Token string `json:"token"`
-131
View File
@@ -1,131 +0,0 @@
package server
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/accountdelete"
"scrabble/backend/internal/game"
)
// Account deletion is legal retention, not erasure (docs/ARCHITECTURE.md §9.1): the account
// row survives as a tombstone while its credentials are journalled + freed, its live
// surfaces anonymised, and its own social/ephemeral data dropped. Messages are kept. The
// step-up is a mailed code for an account with a confirmed email, or a typed phrase for a
// platform-only account (possession-proof is unattainable there, so the phrase is
// anti-impulse only).
// deletePhrase is the fixed confirmation phrase a no-email account types to delete.
// Compared case-insensitively; the client localises only the surrounding instruction.
const deletePhrase = "DELETE"
// deleteRequestResponse tells the client which step-up the account uses.
type deleteRequestResponse struct {
Method string `json:"method"` // "email" | "phrase"
}
// handleRequestDelete starts account deletion: it mails a delete code to an account with a
// confirmed email, or reports the typed-phrase path otherwise.
func (s *Server) handleRequestDelete(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
ctx := c.Request.Context()
hasEmail, err := s.emails.HasEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
if !hasEmail {
c.JSON(http.StatusOK, deleteRequestResponse{Method: "phrase"})
return
}
if err := s.emails.RequestDeleteCode(ctx, uid); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, deleteRequestResponse{Method: "email"})
}
// deleteConfirmBody carries the step-up proof: a mailed code (email account) or the typed
// phrase (platform-only account).
type deleteConfirmBody struct {
Code string `json:"code"`
Phrase string `json:"phrase"`
}
// handleConfirmDelete verifies the step-up and deletes the account.
func (s *Server) handleConfirmDelete(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req deleteConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
ctx := c.Request.Context()
hasEmail, err := s.emails.HasEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
if hasEmail {
if err := s.emails.VerifyDeleteCode(ctx, uid, req.Code); err != nil {
s.abortErr(c, err)
return
}
} else if !strings.EqualFold(strings.TrimSpace(req.Phrase), deletePhrase) {
abortBadRequest(c, "confirmation phrase does not match")
return
}
if err := s.deleteAccount(ctx, uid); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// deleteAccount runs the deletion orchestration after the step-up passed: it resigns the
// account's active games (so opponents are not stranded and robot games end cleanly),
// drops its all-robot games, tombstones + anonymises the account (journalling and freeing
// its credentials), and revokes its sessions. The tombstone is the point of no return —
// its failure aborts; the game cleanup and session revocation around it are best-effort.
func (s *Server) deleteAccount(ctx context.Context, uid uuid.UUID) error {
deleter := accountdelete.NewDeleter(s.db)
if s.games != nil {
if games, err := s.games.ListForAccount(ctx, uid); err == nil {
for _, g := range games {
if g.Status != game.StatusActive {
continue
}
if _, err := s.games.Resign(ctx, g.ID, uid); err != nil {
s.log.Warn("delete: resign game failed", zap.String("game", g.ID.String()), zap.Error(err))
}
}
} else {
s.log.Warn("delete: list games failed", zap.Error(err))
}
}
if _, err := deleter.DropAllRobotGames(ctx, uid); err != nil {
s.log.Warn("delete: drop all-robot games failed", zap.Error(err))
}
if err := deleter.AnonymizeAndTombstone(ctx, uid); err != nil {
return err
}
if s.sessions != nil {
if err := s.sessions.RevokeAllForAccount(ctx, uid); err != nil {
s.log.Warn("delete: revoke sessions failed", zap.Error(err))
}
}
return nil
}
-132
View File
@@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/link"
)
@@ -34,12 +33,6 @@ type linkTelegramBody struct {
ExternalID string `json:"external_id"`
}
// linkVKBody carries a gateway-validated VK identity (the trusted vk user id the
// gateway resolved from the VK ID code exchange).
type linkVKBody struct {
ExternalID string `json:"external_id"`
}
// linkResultResponse is the unified result of a confirm or merge step. Status is
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
// account — the secondary_* fields summarise it for the irreversible confirmation),
@@ -73,89 +66,6 @@ func (s *Server) handleLinkEmailRequest(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true})
}
// unlinkBody carries the provider kind to detach.
type unlinkBody struct {
Kind string `json:"kind"`
}
// handleUnlink detaches a platform identity (telegram or vk) from the caller's
// account, refusing to remove the last identity (ErrLastIdentity). Email is never
// unlinked — it is replaced through the change-email flow — so an email kind is
// rejected. It returns the refreshed profile so the client updates its controls.
func (s *Server) handleUnlink(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req unlinkBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if req.Kind != account.KindTelegram && req.Kind != account.KindVK {
abortBadRequest(c, "only telegram or vk can be unlinked")
return
}
ctx := c.Request.Context()
if err := s.accounts.RemoveIdentity(ctx, uid, req.Kind); err != nil {
s.abortErr(c, err)
return
}
acc, err := s.accounts.GetByID(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
r := s.profileResponse(ctx, acc)
c.JSON(http.StatusOK, linkResultResponse{Status: "unlinked", Profile: &r})
}
// handleChangeEmailRequest mails a confirm-code to a new address for an authenticated
// email change. Like the link request it never signals "taken" up front — a conflict is
// only revealed (non-disclosingly) at confirm.
func (s *Server) handleChangeEmailRequest(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailRequestBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if err := s.emails.RequestChangeCode(c.Request.Context(), uid, req.Email); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleChangeEmailConfirm verifies the code and atomically switches the account to the
// new address, returning the refreshed profile. A new address confirmed by another
// account is refused (ErrEmailTaken → the non-disclosing message), never merged.
func (s *Server) handleChangeEmailConfirm(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
ctx := c.Request.Context()
acc, err := s.emails.ConfirmChange(ctx, uid, req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
r := s.profileResponse(ctx, acc)
c.JSON(http.StatusOK, linkResultResponse{Status: "changed", Profile: &r})
}
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
// required merge.
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
@@ -239,48 +149,6 @@ func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// handleLinkVK attaches a gateway-validated VK identity to the caller or reports a
// required merge.
func (s *Server) handleLinkVK(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkVKBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.ConfirmVK(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
}
// handleLinkVKMerge merges the account owning a gateway-validated VK identity into
// the caller's.
func (s *Server) handleLinkVKMerge(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkVKBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.MergeVK(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
// or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
-4
View File
@@ -25,10 +25,6 @@ func (s *Server) handleProfile(c *gin.Context) {
s.abortErr(c, err)
return
}
// The SPA fetches the profile once per cold app-load, so stamp the account's last
// login time and client IP here (throttled to at most once an hour). Best-effort: it
// feeds the deletion dossier, never blocks the profile read.
_ = s.accounts.StampLastLogin(c.Request.Context(), uid, clientIP(c))
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
}
-13
View File
@@ -29,7 +29,6 @@ import (
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry"
@@ -97,12 +96,6 @@ type Deps struct {
// signal the banner/hint/role console actions emit. A nil Notifier discards
// them (notify.Nop).
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
@@ -131,8 +124,6 @@ type Server struct {
ads *ads.Service
notifier notify.Publisher
console *adminconsole.Renderer
exportKey []byte
renderer *render.Client
public *gin.RouterGroup
user *gin.RouterGroup
@@ -182,12 +173,8 @@ func New(addr string, deps Deps) *Server {
banview: deps.BanView,
ads: deps.Ads,
notifier: notifier,
renderer: deps.Renderer,
http: &http.Server{Addr: addr, Handler: engine},
}
if deps.ExportSignKey != "" {
s.exportKey = []byte(deps.ExportSignKey)
}
s.registerProbes(engine)
s.registerAPIGroups(engine)
s.registerRoutes()
+5 -59
View File
@@ -1,10 +1,6 @@
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
# deploy maps the PROD_-prefixed set the same way. Values that are identical on every
# contour (DICT_VERSION, SMTP_RELAY_HOST/PORT/TLS/USER/PASS, GRAFANA_SMTP_PORT,
# VITE_VK_APP_LINK/ID, the two VK secrets) live as ONE unprefixed Gitea entry, and the
# deploy derives TELEGRAM_MINIAPP_URL / GRAFANA_ROOT_URL / VITE_VK_ID_REDIRECT_URL from
# PUBLIC_BASE_URL. Copy to deploy/.env for a local run (set the derived ones directly).
# deploy maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
#
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
@@ -19,48 +15,12 @@ POSTGRES_PASSWORD=change-me # required
# boot the dawg-data volume preserves versions uploaded through the admin console and
# the active version lives in the DB. On a live volume a changed value is ignored (the
# recorded .seed_version marker wins — the seed-drift guard); change a running
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea
# variable (DICT_VERSION) seeds both contours + pins the CI test suite.
DICT_VERSION=v1.3.1
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
DICT_VERSION=v1.3.0
# --- Logging ----------------------------------------------------------------
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)
# Operator alerts (email). The backend emails the admin on new feedback / word complaints
# (coalesced), and Grafana emails infra alerts. Distinct senders; recipients may be several
# comma-separated addresses. Grafana reuses SMTP_RELAY_HOST/USER/PASS but dials the STARTTLS
# port (it can't do the backend's implicit TLS), GRAFANA_SMTP_PORT. All empty = off.
SMTP_RELAY_ADMIN_FROM= # backend admin-alert From (e.g. alerts@erudit-game.ru)
ADMIN_EMAIL= # backend admin-alert recipient(s), comma-separated
SMTP_RELAY_SERVICE_FROM= # Grafana alert From; the deploy derives a bare address for Grafana (it rejects "Name" <addr>)
SERVICE_EMAIL= # Grafana alert recipient(s), comma-separated
GRAFANA_SMTP_PORT= # Grafana STARTTLS port on SMTP_RELAY_HOST (Selectel: 1126)
GF_SMTP_ENABLED=false # set true to enable Grafana alert emails
# --- Edge / caddy -----------------------------------------------------------
# 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.
@@ -72,13 +32,10 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
VITE_TELEGRAM_BOT_ID=
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_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri. Deploy derives it as PUBLIC_BASE_URL + /app/
VITE_GATEWAY_URL=
# --- Grafana ----------------------------------------------------------------
GRAFANA_ROOT_URL=/_gm/grafana/ # deploy derives PUBLIC_BASE_URL + /_gm/grafana/; set the full https URL for a local run
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
GRAFANA_ADMIN_PASSWORD=admin
# --- Telegram validator + bot -----------------------------------------------
@@ -95,7 +52,7 @@ TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; emp
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/
TELEGRAM_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL=
@@ -104,14 +61,3 @@ TELEGRAM_API_BASE_URL=
# 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=
# VK ID web login (browser VK-identity linking) — a SEPARATE VK "Web" app: the gateway runs
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
GATEWAY_VK_ID_CLIENT_SECRET=
# --- Gateway anti-abuse ------------------------------------------------------
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
# somewhere an attacker would find it; empty disables the trap. Gitea
# TEST_/PROD_GATEWAY_HONEYTOKEN (secret).
GATEWAY_HONEYTOKEN=
+20 -78
View File
@@ -16,7 +16,6 @@ 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. |
| `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). |
| `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. |
| `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. |
@@ -46,18 +45,6 @@ runs `docker compose up -d --build` on the runner host. The prod deploy maps the
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
feeds the compose's `POSTGRES_PASSWORD`, etc.
Three naming classes in Gitea:
- **Per-contour** (`TEST_`/`PROD_<NAME>`) — values that differ between the contours
(bots, hosts, public origin, log level, email senders): the common case below.
- **Shared** (one unprefixed `<NAME>`, no prefix) — values identical on every contour,
stored once: `DICT_VERSION`, `SMTP_RELAY_HOST`/`PORT`/`TLS`/`USER`/`PASS`,
`GRAFANA_SMTP_PORT`, `VITE_VK_APP_LINK`, `VITE_VK_APP_ID`, `GATEWAY_VK_APP_SECRET`,
`GATEWAY_VK_ID_CLIENT_SECRET` (one Selectel relay + one pair of VK apps for all contours).
- **Derived** — not stored at all; the deploy computes them from `PUBLIC_BASE_URL`
(`deploy/write-prod-env.sh`, and the `ci.yaml` deploy step): `TELEGRAM_MINIAPP_URL`
(`+ /telegram/`), `GRAFANA_ROOT_URL` (`+ /_gm/grafana/`), `VITE_VK_ID_REDIRECT_URL`
(`+ /app/`). Set them directly only for a local `.env` run.
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
@@ -74,8 +61,7 @@ compose binds from this directory.
| --- | --- | --- |
| `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>'`. |
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
**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
@@ -94,11 +80,11 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
| `GRAFANA_ROOT_URL` | derived | `/_gm/grafana/` | Grafana root URL (sub-path serving). The deploy derives `PUBLIC_BASE_URL + /_gm/grafana/`; set the full `https://<domain>/_gm/grafana/` only for a local run. |
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
| `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
@@ -106,28 +92,9 @@ 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_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_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable (shared) | _(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 (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
| `SMTP_RELAY_TLS` | variable (shared) | _(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 (shared) | _(empty)_ | Relay SMTP AUTH username. |
| `SMTP_RELAY_PASS` | secret (shared) | _(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). |
| `SMTP_RELAY_ADMIN_FROM` | variable | _(empty)_ | Backend operator-alert sender (new feedback / word complaints), distinct from the confirm-code From. Empty (with `ADMIN_EMAIL`) disables the alert worker. |
| `ADMIN_EMAIL` | variable | _(empty)_ | Backend operator-alert recipient(s); several comma-separated addresses allowed. |
| `SMTP_RELAY_SERVICE_FROM` | variable | _(empty)_ | Grafana infra-alert sender address. |
| `SERVICE_EMAIL` | variable | _(empty)_ | Grafana infra-alert recipient(s); comma-separated allowed (read by the provisioned contact point via `$__env{SERVICE_EMAIL}`). |
| `GRAFANA_SMTP_PORT` | variable (shared) | _(empty)_ | STARTTLS port Grafana dials on `SMTP_RELAY_HOST` (its client can't do the backend's implicit TLS), e.g. Selectel's `1126`. Reuses `SMTP_RELAY_HOST`/`USER`/`PASS`. |
| `GF_SMTP_ENABLED` | variable | `false` | Set `true` to enable Grafana alert emails. |
The six `VITE_*` are **build-args** baked into the gateway and landing images at
The five `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
built once), so changing them requires a rebuild (`--build`), not just a restart.
@@ -138,8 +105,7 @@ at each other on the `internal` network — listed here so they are not mistaken
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
bot-link relay `gateway:9092`), `BACKEND_RENDERER_URL` (→ `renderer:8090`, the image-render
sidecar), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
bot-link relay `gateway:9092`), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
deploy), and all services' `*_OTEL_*_EXPORTER=otlp`
@@ -155,13 +121,14 @@ intentionally **unset** — caddy owns `/_gm` in the contour.
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
a build-time input with **no default** in the images. It is a single Gitea repo variable —
**`DICT_VERSION`** (unprefixed, shared across contours) — so a release bump is **one edit**:
a build-time input with **no default** in the images, so it is set in exactly two places to
move the whole stack — change both to a new release:
- the CI test jobs read it via `.gitea/workflows/ci.yaml`'s top-level
`env.DICT_VERSION: ${{ vars.DICT_VERSION }}` (the unit/integration jobs download that dawg), and
- both deploy jobs feed the same variable to `compose` as the `DICT_VERSION` build-arg that
bakes a **fresh** volume's seed.
1. **CI tests** `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
download that dawg).
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
`DICT_VERSION`).
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
@@ -190,16 +157,6 @@ public site. After `master` is green this workflow is the **only** thing that to
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
**Maintenance page.** During the roll (and any migration window) `prod-deploy.sh` raises a
flag the edge caddy serves a static 503 "технические работы" page from
(`deploy/caddy/maintenance.html`), for the user-facing routes only — `/_gm` (Grafana) stays
reachable. The flag is cleared on any exit (success, a health failure + rollback, or an
error) by a shell trap, so it can never stick on. It arms from this feature's own deploy
onward (the caddy carrying the gate must be live first). This is **not** a zero-downtime
deploy — the backend is a single stateful instance (in-memory game state + push hub, no
Redis), so its swap still blips (clients auto-reconnect the live stream); the page just
makes the window graceful instead of raw 502s.
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
`git describe --tags` into every image tag, every binary (`-ldflags``pkg/version`
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
@@ -232,31 +189,16 @@ together with the fresh CA.
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
players arrive. The per-container memory caps are enforced (Compose v2 → cgroup), so no one
service can eat all RAM, but they **overcommit** the host (~2.8 GiB of caps vs 1.9 GiB) — a
**1 GiB swap file** (Ansible `common` role, `swap_size`, `vm.swappiness=10`) cushions a
simultaneous spike into swap instead of the kernel OOM-killer. The `host_mem_low` Grafana
alert fires under 10% available.
players arrive.
**Shared Gitea set** (one unprefixed entry each, used by every contour) — secrets:
`GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET, SMTP_RELAY_USER, SMTP_RELAY_PASS`;
variables: `DICT_VERSION, SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, GRAFANA_SMTP_PORT,
VITE_VK_APP_LINK, VITE_VK_APP_ID`. **Derived from `PUBLIC_BASE_URL` at deploy** (not stored):
`TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL, VITE_VK_ID_REDIRECT_URL`.
**`PROD_` Gitea set** (per-contour, 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,
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
BOTLINK_BOT_KEY}`; variables:
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
GF_SMTP_ENABLED}`. The test contour uses the same names under `TEST_`, minus the prod-only
infra (`MAIN_HOST`/`TG_HOST`/`REGISTRY_*`/`SSH_*`/`BOTLINK_*`, which the test deploy generates
or runs locally) and plus `TEST_AWG_CONF` (the bot's VPN egress).
TELEGRAM_PROMO_BOT_TOKEN, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; variables:
`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,
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
VITE_TELEGRAM_GAME_CHANNEL_NAME}`.
## Host-side setup (outside this repo)
-8
View File
@@ -19,11 +19,3 @@ scrabble_base_dir: /opt/scrabble
# the host's own containers (and any ad-hoc runs) rotate identically.
docker_log_max_size: "10m"
docker_log_max_file: "3"
# Swap file as an OOM cushion. The per-container memory caps (docker-compose.prod.yml)
# sum to more than the tight main host's RAM (2 vCPU / 1.9 GiB), so a simultaneous
# spike could otherwise hit the kernel OOM-killer and it might pick postgres. A small
# swap absorbs the overshoot; swappiness stays low so swap is a cushion, not a hot
# path (a slow service beats a killed one). Set swap_size to "0" to skip provisioning.
swap_size: "1G"
swap_swappiness: 10
@@ -150,57 +150,6 @@
enabled: true
state: started
# --- Swap file (OOM cushion) ---------------------------------------------------
# The prod main host is tight (1.9 GiB) and the per-container memory caps
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
# the kernel OOM-killer (which might pick postgres). A small swap absorbs the
# overshoot. Idempotent; skipped when swap_size == "0". Builtin-only (no ansible.posix).
- name: Check whether the swap file is already active
ansible.builtin.command: swapon --show=NAME --noheadings
register: swap_active
changed_when: false
when: swap_size != "0"
- name: Allocate the swap file
ansible.builtin.command:
cmd: "fallocate -l {{ swap_size }} /swapfile"
creates: /swapfile
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
- name: Secure the swap file (0600)
ansible.builtin.file:
path: /swapfile
owner: root
group: root
mode: "0600"
when: swap_size != "0"
- name: Format and enable the swap file
ansible.builtin.shell: "mkswap /swapfile && swapon /swapfile"
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
- name: Persist the swap file in /etc/fstab
ansible.builtin.lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
regexp: '^/swapfile\s'
state: present
when: swap_size != "0"
- name: Keep swap a cushion, not a hot path (low swappiness)
ansible.builtin.copy:
dest: /etc/sysctl.d/60-scrabble-swap.conf
mode: "0644"
content: "vm.swappiness = {{ swap_swappiness }}\n"
register: swappiness_conf
when: swap_size != "0"
- name: Apply swappiness now
ansible.builtin.command: sysctl --system
changed_when: false
when: swap_size != "0" and swappiness_conf is changed
# --- Deploy directories --------------------------------------------------------
- name: Create the scrabble base directories
-14
View File
@@ -1,14 +0,0 @@
# blackbox_exporter probe modules. tls_cert opens a verified TLS connection to the edge
# caddy so Prometheus can read probe_ssl_earliest_cert_expiry — the signal behind the
# certificate-renewal-failure alert. The SNI is the production public host, whose cert the
# edge caddy serves once it does its own ACME; on the test contour caddy is HTTP-only
# (behind the host caddy), so the probe finds nothing on :443 and the cert metric is absent.
modules:
tls_cert:
prober: tcp
timeout: 5s
tcp:
tls: true
tls_config:
server_name: erudit-game.ru
insecure_skip_verify: false
+1 -34
View File
@@ -33,39 +33,6 @@
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
header Alt-Svc clear
# Maintenance gate. While the deploy holds the flag file /srv/maint/on (touched by
# prod-deploy.sh around a rolling swap, removed on the script's exit), serve a static
# 503 "works in progress" page instead of proxying to a mid-recreate upstream —
# a graceful signal rather than raw 502s. Operator surfaces (/_gm) are excluded so
# Grafana stays reachable during the window. Caddy stat()s the flag per request, so
# toggling it needs no reload; the page + flag live in the caddy config dir bind-mounted
# read-only at /srv/maint (docker-compose.yml). The test contour never sets the flag
# (only prod-deploy.sh does), so this is inert there.
@maintenance {
not path /_gm /_gm/*
file {
root /srv/maint
try_files on
}
}
handle @maintenance {
error 503
}
handle_errors {
@maint503 expression {err.status_code} == 503
handle @maint503 {
root * /srv/maint
rewrite * /maintenance.html
header Retry-After 120
# Distinctive maintenance marker so the SPA can tell a planned window apart from
# a transient upstream 503 and show its own dimmed overlay (an in-session user
# never sees this static page — it only renders on a fresh load). The header
# rides every gated response, incl. the Connect/gRPC edge the app polls.
header X-Scrabble-Maintenance 1
file_server
}
}
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
@gm path /_gm /_gm/*
handle @gm {
@@ -86,7 +53,7 @@
# 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
# tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /scrabble.edge.v1.Gateway/*
handle @gateway {
reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot
-44
View File
@@ -1,44 +0,0 @@
<!doctype html>
<!--
Served with 503 by the edge caddy while the deploy holds the maintenance flag
(/srv/maint/on, toggled by deploy/prod-deploy.sh around a rolling swap). Static and
self-contained (no upstream, no external assets) so it renders while the backend /
gateway are mid-recreate.
-->
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Эрудит — технические работы</title>
<style>
:root { color-scheme: light dark; }
html, body { height: 100%; margin: 0; }
body {
display: flex; align-items: center; justify-content: center;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #f4f1ea; color: #2b2b2b;
padding: 24px; box-sizing: border-box;
}
@media (prefers-color-scheme: dark) { body { background: #1d1b17; color: #e8e4da; } }
.card { max-width: 30rem; text-align: center; line-height: 1.5; }
.tile {
display: inline-block; width: 3.5rem; height: 3.5rem; line-height: 3.5rem;
font-size: 2rem; font-weight: 700; border-radius: 10px;
background: #d9b451; color: #2b2b2b; box-shadow: 0 2px 4px rgba(0,0,0,.25);
margin-bottom: 1.25rem;
}
h1 { font-size: 1.4rem; margin: 0 0 .5rem; }
p { margin: .35rem 0; }
.en { opacity: .7; font-size: .95rem; }
</style>
</head>
<body>
<main class="card">
<div class="tile">Э</div>
<h1>Технические работы</h1>
<p>Идёт короткое обновление игры. Мы скоро вернёмся — обновите страницу через пару минут.</p>
<p class="en">Scrabble is briefly down for an update. Please refresh in a couple of minutes.</p>
</main>
</body>
</html>
-7
View File
@@ -50,13 +50,6 @@ services:
limits:
memory: 384M
renderer:
image: ${REGISTRY:?set REGISTRY}/scrabble-renderer:${TAG:?set TAG}
deploy:
resources:
limits:
memory: 160M
validator:
image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG}
deploy:
+4 -113
View File
@@ -61,36 +61,6 @@ services:
memory: 512M
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:
container_name: scrabble-backend
image: scrabble-backend:latest
@@ -110,15 +80,9 @@ services:
depends_on:
postgres:
condition: service_healthy
renderer:
condition: service_healthy
environment:
# 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
# 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
# for bursts. Postgres (2 cores / 512 MiB) handles it.
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
@@ -136,25 +100,6 @@ services:
# 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).
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:-}
# Operator alert emails (new feedback / word complaints): a distinct sender and the
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# 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
# inherits). The admin console writes new version subdirectories here, and the
@@ -189,9 +134,6 @@ services:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
@@ -209,14 +151,6 @@ services:
# 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:-}
# VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code
# exchange runs server-side under the VK ID "Web" app's protected key. This is a
# SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id
# and redirect URL are the same values the SPA builds its authorize URL from (one
# source each). All three empty disables the link.vk.* ops.
GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-}
GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-}
GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
# 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
# listeners stay on the internal network (the bot shares the VPN netns); in prod
@@ -227,12 +161,10 @@ services:
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
# honeypot/honeytoken. Off by default: it bans by client IP, which is only real
# in prod — the test contour arrives as one shared NAT address, so a ban there
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
# in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN
# is the planted bearer trap, fed from the per-contour TEST_/PROD_GATEWAY_HONEYTOKEN
# secret; empty (unset secret) leaves the trap off.
# honeypot/honeytoken. Off by default: it bans by client IP, which is only
# real in prod — the test contour arrives as one shared NAT address, so a ban
# there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
# these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
@@ -275,9 +207,6 @@ services:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
@@ -428,11 +357,6 @@ services:
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
# Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the
# `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503
# from here while the flag exists (read-only mount — the deploy writes the flag on the
# host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh.
- ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint:ro
- caddy-data:/data
deploy:
resources:
@@ -520,21 +444,6 @@ services:
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
# dashboards poll and do not need Live.
GF_LIVE_MAX_CONNECTIONS: "0"
# SMTP for alert emails, reusing the shared relay host + credentials + the SERVICE
# From/recipient. Grafana's client speaks STARTTLS (not the backend's implicit-TLS
# port), so it dials the relay host on its STARTTLS port (GRAFANA_SMTP_PORT).
# Disabled unless GF_SMTP_ENABLED.
GF_SMTP_ENABLED: ${GF_SMTP_ENABLED:-false}
GF_SMTP_HOST: ${SMTP_RELAY_HOST:-}:${GRAFANA_SMTP_PORT:-}
GF_SMTP_USER: ${SMTP_RELAY_USER:-}
GF_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
# A BARE address (Grafana rejects the "Name" <addr> form); the deploy derives it from
# SMTP_RELAY_SERVICE_FROM, splitting off the display name into GRAFANA_SMTP_FROM_NAME.
GF_SMTP_FROM_ADDRESS: ${GRAFANA_SMTP_FROM_ADDRESS:-}
GF_SMTP_FROM_NAME: ${GRAFANA_SMTP_FROM_NAME:-Erudit Alerts}
GF_SMTP_STARTTLS_POLICY: MandatoryStartTLS
# The alert recipient(s), read by the provisioned contact point via $__env{SERVICE_EMAIL}.
SERVICE_EMAIL: ${SERVICE_EMAIL:-}
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
@@ -585,24 +494,6 @@ services:
memory: 64M
networks: [internal]
# blackbox_exporter lets Prometheus alert on TLS certificate expiry (a Caddy ACME
# renewal failure) via probe_ssl_earliest_cert_expiry. It probes the edge caddy that
# terminates TLS (prod: the published scrabble-caddy; the test contour has no compose
# caddy, so the probe simply finds no target and the cert metric is absent — the rule is
# absent-safe). See prometheus.yml and grafana alerting rules.
blackbox_exporter:
container_name: scrabble-blackbox-exporter
image: prom/blackbox-exporter:v0.25.0
restart: unless-stopped
logging: *default-logging
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
deploy:
resources:
limits:
memory: 64M
networks: [internal, edge]
networks:
internal:
name: scrabble-internal
+1 -44
View File
@@ -4,7 +4,7 @@
"tags": ["scrabble"],
"timezone": "",
"schemaVersion": 39,
"version": 2,
"version": 1,
"refresh": "30s",
"time": { "from": "now-7d", "to": "now" },
"panels": [
@@ -29,49 +29,6 @@
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"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}}" }]
},
{
"type": "timeseries",
"title": "Unsupported-engine screens (cumulative by reason)",
"description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }]
},
{
"type": "timeseries",
"title": "Unsupported engines by Chromium (rate)",
"description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }]
}
]
}
@@ -1,13 +0,0 @@
# Grafana alerting contact point: the operator's alert mailbox, read from the
# SERVICE_EMAIL container env (see docker-compose.yml grafana), so it stays per-contour.
# SERVICE_EMAIL may hold several comma-separated addresses.
apiVersion: 1
contactPoints:
- orgId: 1
name: ops-email
receivers:
- uid: ops_email
type: email
settings:
addresses: $__env{SERVICE_EMAIL}
singleEmail: true
@@ -1,10 +0,0 @@
# Notification policy: every alert routes to the operator email, grouped so a burst is one
# message, with a 4-hour re-notify while still firing.
apiVersion: 1
policies:
- orgId: 1
receiver: ops-email
group_by: ['alertname']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
@@ -1,214 +0,0 @@
# Grafana provisioned alert rules for the Scrabble contour. Each rule is a Prometheus
# instant query (refId A) fed into a threshold expression (refId C). noDataState/execErrState
# are OK so an absent metric never raises a false alert — notably cert-expiry, whose blackbox
# probe has no target on the test contour (caddy is HTTP-only there). Metric names are the
# real ones Prometheus exposes (edge_request_* from the gateway via the collector,
# node_*/pg_*/probe_ssl_* from the exporters). All route to the ops-email contact point.
apiVersion: 1
groups:
- orgId: 1
name: scrabble-service
folder: Alerts
interval: 1m
rules:
- uid: svc_target_down
title: Scrape target down
condition: C
for: 3m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: up, instant: true }
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [1] }
labels: { severity: critical }
annotations: { summary: 'A Prometheus scrape target is down (up < 1).' }
- uid: edge_error_rate
title: Gateway internal-error rate high
condition: C
for: 10m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: sum by (service_name) (rate(edge_request_duration_count{result="internal"}[5m]))
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0.05] }
labels: { severity: warning }
annotations: { summary: 'Sustained internal (5xx-equivalent) errors at the edge.' }
- uid: edge_latency_p99
title: Gateway request latency p99 high
condition: C
for: 10m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: histogram_quantile(0.99, sum by (le, service_name) (rate(edge_request_duration_bucket[5m])))
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [1] }
labels: { severity: warning }
annotations: { summary: 'Edge request p99 latency above 1s.' }
- uid: tls_cert_expiry
title: TLS certificate nearing expiry
condition: C
for: 15m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: (probe_ssl_earliest_cert_expiry - time()) / 86400
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [20] }
labels: { severity: critical }
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
- orgId: 1
name: scrabble-host
folder: Alerts
interval: 1m
rules:
- uid: host_mem_low
title: Host memory low
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [0.1] }
labels: { severity: critical }
annotations: { summary: 'Under 10% host memory available.' }
- uid: host_disk_low
title: Host disk low
condition: C
for: 10m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: min(node_filesystem_avail_bytes / node_filesystem_size_bytes)
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [0.1] }
labels: { severity: critical }
annotations: { summary: 'A host filesystem is under 10% free.' }
- uid: host_cpu_high
title: Host CPU saturated
condition: C
for: 10m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: 1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]))
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0.9] }
labels: { severity: warning }
annotations: { summary: 'Host CPU above 90% for 10 minutes.' }
- uid: pg_connections_high
title: Postgres connections high
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: sum(pg_stat_activity_count) / max(pg_settings_max_connections)
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0.8] }
labels: { severity: warning }
annotations: { summary: 'Postgres using over 80% of max_connections.' }
-17
View File
@@ -42,15 +42,6 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}"
PG_USER="${POSTGRES_USER:-scrabble}"
PG_DB="${POSTGRES_DB:-scrabble}"
# Edge maintenance page. caddy serves a static 503 "works in progress" page for the
# user-facing routes while this flag file exists (deploy/caddy/Caddyfile). We hold it
# across the roll / migration window and clear it on ANY exit — success, a health
# failure + rollback, or an unexpected error — via the trap, so users get a graceful
# page instead of raw mid-swap 502s and the flag can never get stuck on.
MAINT_FLAG="${MAINT_FLAG:-${SCRABBLE_CONFIG_DIR:-/opt/scrabble}/caddy/on}"
maint_off() { rm -f "$MAINT_FLAG" 2>/dev/null || true; }
trap maint_off EXIT
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
export REGISTRY
# otelcol joins the host docker group to read the socket; the GID varies per host.
@@ -128,13 +119,6 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then
exit 0
fi
# Existing stack: raise the maintenance page for the whole roll (and any migration
# window). It shows once caddy carries the gate (from this feature's own deploy
# onward); the EXIT trap lowers it however this run ends.
mkdir -p "$(dirname "$MAINT_FLAG")"
: > "$MAINT_FLAG"
echo "maintenance page raised ($MAINT_FLAG)"
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
if [ "$MIGRATION" = 1 ]; then
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
@@ -150,7 +134,6 @@ fi
# Roll one service at a time, least -> most dependent; any failure rolls everything back.
roll postgres health_postgres || { rollback; exit 1; }
roll renderer health_running scrabble-renderer || { rollback; exit 1; }
roll backend health_backend || { rollback; exit 1; }
roll gateway health_running scrabble-gateway || { rollback; exit 1; }
roll landing health_landing || { rollback; exit 1; }
-18
View File
@@ -23,21 +23,3 @@ scrape_configs:
- job_name: node
static_configs:
- targets: ["node_exporter:9100"]
# TLS certificate expiry of the edge caddy (probe_ssl_earliest_cert_expiry), for the
# certificate-renewal-failure alert. Effective on prod, where caddy terminates TLS on
# :443; on the test contour caddy is HTTP-only, so the probe finds nothing and the metric
# is absent (the alert rule is absent-safe). The exporter probes the target passed as a
# scrape parameter and answers on its own :9115.
- job_name: blackbox_tls
metrics_path: /probe
params:
module: [tls_cert]
static_configs:
- targets: ["caddy:443"]
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox_exporter:9115
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# Render the prod bot-host env.bot.sh from the workflow job environment.
#
# Sourced identically by prod-deploy (deploy-bot) and prod-rollback
# (rollback-bot) so the two paths cannot drift (see deploy/write-prod-env.sh
# for the same rationale on the main host).
#
# Usage: BOT_IMAGE=<image ref> bash deploy/write-prod-bot-env.sh <out-path>
#
# Every other value comes from the caller's environment (the job `env:` block).
out="${1:?usage: write-prod-bot-env.sh <out-path>}"
# The bot's Mini App URL is the same public origin the SPA serves; derive it
# rather than storing a second copy.
base="${PUBLIC_BASE_URL%/}"
TELEGRAM_MINIAPP_URL="$base/telegram/"
cat > "$out" <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$BOT_IMAGE'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
# Render the prod main-host runtime env.sh from the workflow job environment.
#
# Sourced identically by prod-deploy (deploy-main) and prod-rollback
# (rollback-main) so the two paths cannot drift: a rollback recreates the
# containers, so it must re-render the SAME runtime env a full deploy does —
# otherwise transactional email, VK login and Grafana alerts silently go dark
# after a rollback until the next full deploy.
#
# Usage: APP_VERSION=<tag> bash deploy/write-prod-env.sh <out-path>
#
# Every other value comes from the caller's environment (the job `env:` block,
# vars.* / secrets.*). Mirrors the compose interpolation contract in
# deploy/.env.example; keep in sync with deploy/docker-compose{,.prod}.yml.
out="${1:?usage: write-prod-env.sh <out-path>}"
# Derive the public URLs from the one canonical origin instead of storing each
# under its own variable. The path suffixes are structural (SPA routes / the
# Caddy /_gm sub-path), identical on every contour.
base="${PUBLIC_BASE_URL%/}"
TELEGRAM_MINIAPP_URL="$base/telegram/"
GRAFANA_ROOT_URL="$base/_gm/grafana/"
VITE_VK_ID_REDIRECT_URL="$base/app/"
# Grafana needs a BARE from-address (it rejects the "Name" <addr> form the backend
# go-mail accepts, and validates it even when SMTP is disabled — a bad value
# crash-loops Grafana). Split the display-format SERVICE From into address + name;
# the backend keeps the full form.
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
GRAFANA_SMTP_FROM_NAME=''
case "$svc_from" in
*"<"*">"*)
GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
*) GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
esac
cat > "$out" <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$APP_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_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 SMTP_RELAY_ADMIN_FROM='$SMTP_RELAY_ADMIN_FROM'
export ADMIN_EMAIL='$ADMIN_EMAIL'
export SMTP_RELAY_SERVICE_FROM='$SMTP_RELAY_SERVICE_FROM'
export GRAFANA_SMTP_FROM_ADDRESS='$GRAFANA_SMTP_FROM_ADDRESS'
export GRAFANA_SMTP_FROM_NAME='$GRAFANA_SMTP_FROM_NAME'
export SERVICE_EMAIL='$SERVICE_EMAIL'
export GRAFANA_SMTP_PORT='$GRAFANA_SMTP_PORT'
export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
+12 -160
View File
@@ -232,77 +232,21 @@ arrive from a platform rather than completing a mandatory registration).
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
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
TTL, ≤ 5 attempts) is sent as a **branded HTML + plain-text email** through a
`Mailer` seam (go-mail over the shared relay — TLS chosen by port, implicit on
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). The email
also carries a **one-tap confirm deeplink** (`/app/#/confirm/<token>`, an opaque
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
profile-refresh to the in-app session, a change switches the account's email in place,
and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never
sent to the server), so a plain link prefetch cannot reach it, and the manual
six-digit code is the fallback if an aggressive scanner runs the page. 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
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
development log mailer when none is configured) and, once verified, attaches a
confirmed email identity. Accounts and identities use application-generated
**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.
- **Linking** is initiated from an authenticated profile and proves
control of the identity before attaching it: **email** through the confirm-code
flow, **Telegram** through the web **Login Widget** (validated by the validator,
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
passes the trusted `external_id` to the backend, as for `auth.telegram`), and **VK**
through **VK ID web login** (`gateway/internal/vkid`): the browser runs the VK ID raw
OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no `@vkid/sdk`
and the gateway completes the **confidential code exchange** server-side under the VK
"Web" app's protected key (a distinct VK app from the Mini App) to obtain the trusted
`external_id`. A browser has no signed launch parameters, so unlike the VK Mini App
(offline HMAC, §12) this makes an outbound call to `id.vk.com`, and it is web-only — a
full-page redirect would strand a native Mini App webview. The
passes the trusted `external_id` to the backend, as for `auth.telegram`). The
request step **always** sends/accepts the proof (no pre-send "already taken"
signal, so a probe cannot enumerate registered addresses); a required **merge**
is revealed **only after** the proof is verified and is performed behind an
explicit, irreversible confirmation (for VK, whose authorization code is single-use,
the merge re-authorizes for a fresh code). A free identity is simply attached (and a
explicit, irreversible confirmation. A free identity is simply attached (and a
guest is promoted to durable, clearing `is_guest`).
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one
method remains. **Email is never unlinked — it is changed.**
- **Change email** mails a confirm-code (`purpose=change`) to the new address on the
authenticated account and, on confirm (code or one-tap deeplink), **atomically
replaces** the account's email identity with the new one, freeing the old address. A
new address already confirmed by **another** account is refused **without disclosure**
(a neutral "check the address or contact support") and **never merged** — the anti-
enumeration check is only reachable by someone who controls the new mailbox.
- **Deletion is legal retention, not erasure** (`internal/accountdelete`). The account row
survives as a **tombstone** (`accounts.deleted_at`) — its chat/complaint foreign keys
have no cascade, so a hard delete is impossible. `AnonymizeAndTombstone` **journals**
every live identity into an append-only **`retained_identities`** log (the legal dossier)
then removes it, freeing the `(kind, external_id)` for a new account to reuse; it scrubs
the live `display_name`**`[Deleted]`** (an unspoofable sentinel — the editable-name
rule forbids brackets) while snapshotting the real name into `deleted_display_name`,
anonymises the game-seat snapshots, and drops the account's own friendships / blocks /
invitations / friend-codes / drafts / pending-codes. **Chat, feedback and complaints are
kept.** The orchestration a layer up resigns the account's active games (so opponents are
not stranded), **drops its all-robot games** (no human opponent; children cascade), and
revokes its sessions. Every credential detachment — **unlink, email change, delete and a
merge collision** — writes a `retained_identities` row (`reason`), so the dossier keeps the
full timeline. (A merge that would otherwise leave the survivor with two identities of one
kind — e.g. each account held a confirmed email — keeps the primary's and journals the
secondary's with `reason=merge` before dropping it.)
Step-up is a mailed code (`purpose=delete`, no deeplink — a stray click must not delete;
`ConfirmByToken` refuses a delete token) for an email account, else a typed phrase.
`last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled
to once an hour). A **two-year TTL reaper** (from the event) purges the whole dossier —
the journal, plus a deleted account's feedback thread and dossier PII — while the chat and
the tombstone row stay.
- **Merge** retires the account that owns the linked identity into the **current**
account, in a single transaction (`internal/accountmerge`): statistics summed
(counters incl. moves/hints added, max points kept, and the per-variant best moves
@@ -841,40 +785,11 @@ the same rows and is likewise self-contained — we ship our own writer (the sol
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
otherwise), so an in-progress journal is never leaked mid-play.
**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text
and the rendered **PNG of the final position** — travel one uniform route on every
platform: the client calls the authenticated `game.export_url` op, the backend mints a
**relative, HMAC-signed, short-lived path**
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL,
`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no
service ever needs to know the public host) and hands it to the best affordance
each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the
chooser there is the native showPopup — activation-free bridge calls end to end),
Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both
formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a
mobile browser, or a plain anchor download (desktop browsers and the VK desktop
iframe). The gateway serves the bytes via
`http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager
hangs on chunked bodies of unknown length). The GET is the gateway's
**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the
per-IP public rate limiter): the native download calls carry no cookies or headers, so
the URL's signature — verified by the backend on its `/api/v1/public` group in constant
time, every failure a uniform 404 — is the whole grant, and minting requires an
authenticated caller on a finished game. The PNG is rasterized on demand by the
**`renderer` sidecar** (internal-only Node + skia-canvas running the same
`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift;
`renderer/README.md`); the backend rebuilds the render payload from the journal +
`engine.AlphabetTable`, and the client's date locale, IANA time zone and UI-localized
non-play labels ride the signed URL so the server render matches the player's
presentation. Nothing is stored: the artifact is re-derived from the immutable
finished journal on each GET. The only degraded platform is a legacy Telegram client
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
image option is not offered.
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
otherwise), so an in-progress journal is never leaked mid-play; the client
shares the `.gcg` file via the Web Share API where available; an Android in-app WebView
(Telegram / VK) has no Web Share and silently ignores an `<a download>`, so there it copies the GCG
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
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 —
@@ -980,25 +895,7 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. A
non-default campaign may also carry an optional **colour override** (background, text, link) in two
sets — one for every theme, one for the dark theme only — plus an **urgent** flag. The colours ride
the same block (six optional strings appended to each `BannerCampaign` on the wire — trailing,
backward-compatible); the client resolves the cascade (dark ← dark ?? all, light ← all) and derives
the strip's border from the background in JS (no CSS `color-mix`, for the old-WebView floor).
**Urgent is resolved entirely server-side, with no wire field**: while any enabled, in-window urgent
campaign exists, `computeActiveSet` returns *only* the urgent campaigns (preempting the timed set and
the default remainder) and `bannerFor` **skips the eligibility gate** for that feed, so an urgent
notice reaches every viewer — paid, hint-holding or `no_banner` included. There is no instant
broadcast on an urgent toggle (the notify path is per-user); an urgent campaign appears on each
viewer's next `profile.get`. The default campaign stays plain (no colours, never urgent), enforced by
both the service and a DB CHECK. The same
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
replay, so a **backgrounded Mini App** — suspended while the user is in their mail app tapping the
link — misses the event; while an add-email confirmation is pending the client therefore also
**polls `profile.get`** (and on foreground regain) as a fallback until the address lands.
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session.
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
@@ -1029,19 +926,6 @@ link — misses the event; while an add-email confirmation is pending the client
both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the
stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour
host resolved only the root cgroup — a separate-XFS `/var/lib/docker`.)
- **Alerting.** Grafana emails infra alerts through the shared relay (its own SMTP on the
STARTTLS port) to `SERVICE_EMAIL`, from provisioned rules
(`deploy/grafana/provisioning/alerting/`): a scrape-target down, the gateway's
internal-error rate and p99 latency (`edge_request_*`), host memory/disk/CPU
(node_exporter), Postgres connection saturation (postgres_exporter), and **TLS certificate
expiry < 20 days** — a Caddy ACME-renewal-failure signal from a **`blackbox_exporter`**
probe of the edge caddy (`probe_ssl_earliest_cert_expiry`). Every rule is `noDataState=OK`,
so an absent metric never false-alerts — notably the cert probe, which has no TLS target on
the HTTP-only contour caddy. Separately, the backend's **admin-alert worker**
(`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a
distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive,
**coalescing** a burst into one digest per interval; both recipients may be several
comma-separated addresses. Both paths are inert unless configured.
- Per-request server-side timing via gin middleware from day one (the access log
carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement.
@@ -1071,31 +955,6 @@ link — misses the event; while an add-email confirmation is pending the client
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
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.
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported`
unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
report. The gateway folds it into `unsupported_engine_total` (`reason` =
no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
on the **Scrabble — Users** dashboard beside app opens.
- **Rate-limit observability:** every limiter rejection increments the gateway
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;
@@ -1226,14 +1085,7 @@ parameters from the URL and the gateway verifies them in-process — §12); a st
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,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
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
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
`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
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
+17 -96
View File
@@ -16,20 +16,14 @@ 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
read-only profile. It also handles managing friends (including one-time friend
codes) and blocks, friend-game invitations, editing the profile and binding an
email, the statistics screen, and the in-game history viewer with GCG / PNG-image export.
email, the statistics screen, and the in-game history viewer with GCG 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
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.
A public **landing page** at the site root introduces the game, switches language and
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
theme, and links to the matching per-language Telegram channel; the game itself runs at
`/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. 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.
(it follows the system scheme, not the saved preference); its language choice is saved.
### First-run onboarding
The first time a player opens the app it walks them through the interface with a light
@@ -61,16 +55,6 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l
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. The email
also carries a **one-tap link** that confirms the address — or signs the player straight
in — in a single tap; opening it in another browser reflects in the app at once, and a
mail scanner that merely fetches the link cannot reach it — the token rides the URL
fragment, which is never sent to the server.
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
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
@@ -96,6 +80,10 @@ reconnect), and pending reads resume on their own — the interface stays usable
flashing a red banner each time.
### Accounts, linking & merge
_Sign-in is currently provider-only, so the in-profile linking UI is temporarily hidden; it
returns once the anonymous `/app/` guest (whose upgrade path this is) ships. The flow below
describes it for when it does._
First platform contact auto-provisions a durable account. From the profile a player
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
who links their first identity becomes a durable account. The "already taken" status
@@ -107,30 +95,6 @@ when a guest links an identity that already has a durable account, where the dur
account is kept and the guest's games move into it. A merge is blocked only while the
two accounts share a game still in progress.
The profile lists the account's **sign-in methods**. On the web a player can add
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
back); inside a Mini App the host platform is already linked, and its own sign-in-method
row is hidden — the platform the player is currently signed in through cannot be unlinked
from there, only the other provider's row is shown. Linking a provider that
already belongs to another account offers the same irreversible **merge** as email
linking. A linked provider can be **unlinked** — except the last
remaining sign-in method, which is refused so the account stays reachable. **Email is
never unlinked; it is changed**: the player enters a new address, confirms a code
mailed to it, and the account switches to it atomically, freeing the old address. A new
address that already belongs to another account is refused with a neutral "check the
address or contact support" — the switch never merges and never reveals the other
account.
A player can **delete their account**. This is a legal-retention removal, not an erasure:
the account is deactivated and its live surfaces anonymised (opponents see "[Deleted]"), its
sign-in methods are freed for reuse, and its sessions are revoked — but a dossier (the
credentials that were linked, the last login, and the player's messages) is retained for
the operator and purged after two years. Confirming deletion needs a mailed code for an
account with an email, or a typed phrase otherwise. Active games are forfeited so opponents
are not stranded; solo games against the AI are removed, while games with a human opponent
are kept under the anonymised name and the player's chat stays. Reopening the app after
deletion simply creates a fresh account.
### Lobby & matchmaking
On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles
spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the
@@ -348,44 +312,12 @@ 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.
### History & statistics
Finished games are archived in a dictionary-independent form and exportable in
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
the final position; the export is offered **only once a game is finished**, and
never for an honest-AI practice game (a live game's export would leak the move
journal; an AI game is throwaway). The format chooser is 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):
Finished games are archived in a dictionary-independent form and exportable to
GCG; the export is offered **only once a game is finished**, and never for an
honest-AI practice game (a live game's export would leak the move journal; an AI
game is throwaway). The client shares the `.gcg` file where the platform supports
it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file
download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). It
also shows the player's **move count** (their plays — passes and exchanges do not
@@ -485,19 +417,8 @@ through**, regardless of their interface language. The client rotates the eligib
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
and a campaign with several messages shows them in turn.
A non-default campaign can carry its own **colours** — background, text and link — so a sponsored or
operational message stands out from the neutral strip. Colours come in two sets: one that applies on
**every theme**, and an optional second that overrides the **dark theme** only, so a campaign reads
well on both; the strip's border is derived from the background automatically. A campaign can also be
marked **urgent**: an urgent campaign is shown to **everyone** — even paid players, hint holders and
`no_banner` users — and, while it is live, it is the **only** thing the strip shows (ordinary
campaigns and the house default step aside). Urgent is for genuine system notices (maintenance,
outages), not advertising.
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a
non-default campaign's optional override colours (a native colour picker with a live light-and-dark
preview of the strip) and mark it urgent, and set the global display **timings** (how long a message
holds, the scroll of an over-long message, and the fade-out → gap → fade-in transition between
messages). The default campaign cannot be deleted, keeps at least one message, and stays plain (no
colours, never urgent).
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
keeps at least one message.
+17 -96
View File
@@ -16,22 +16,15 @@ top-1 подсказку, безлимитную проверку слова с
профиль только для чтения. Он также включает управление друзьями (в т.ч.
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
редактирование профиля и привязку email, экран статистики и просмотр истории
партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
открывается на **русском** — язык браузера не определяется автоматически (роботы
рендерят страницу с произвольным языком, и автоопределение сделало бы
проиндексированный контент недетерминированным); сохранённый выбор 🌐 по-прежнему
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница.
системной настройки, а не из сохранённой), выбор языка сохраняется.
### Первый запуск: онбординг
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
@@ -65,17 +58,7 @@ launch-параметрам VK (их проверяет gateway), и при пе
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
тихого повтора «не удалось
загрузить» действует и внутри VK.
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
он едет в URL-фрагменте, который не уходит на сервер.
Telegram держит **единого бота**: все игроки пользуются одним
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
@@ -100,6 +83,10 @@ Telegram держит **единого бота**: все игроки поль
рабочим вместо красного баннера каждый раз.
### Аккаунты, привязка и слияние
_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он
вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание
ниже — на этот случай._
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
@@ -111,29 +98,6 @@ Telegram держит **единого бота**: все игроки поль
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
показывается только плашка другого провайдера. Привязка провайдера, уже
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
email. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
старый. Новый адрес, уже принадлежащий другому аккаунту, отклоняется нейтральным
«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает
аккаунты и не раскрывает чужой.
Игрок может **удалить аккаунт**. Это удаление с юридическим удержанием, а не стирание:
аккаунт деактивируется, живые поверхности обезличиваются (соперники видят «[Deleted]»),
способы входа освобождаются под повторную регистрацию, сессии отзываются — но досье
(какие креды были привязаны, последний вход, сообщения игрока) сохраняется для оператора и
чистится через два года. Подтверждение удаления — код на почту (если у аккаунта есть
e-mail) либо ввод фразы. Активные игры форфейтятся, чтобы не бросать соперников; одиночные
игры против ИИ удаляются, а игры с людьми сохраняются под обезличенным именем, чат игрока
остаётся. Если снова открыть приложение после удаления — создаётся новый аккаунт.
### Лобби и подбор
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
@@ -356,44 +320,12 @@ Telegram.
канал для тех, кто пишет боту напрямую, а не через приложение.
### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются в
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — **нативный попап 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 откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** и никогда — для
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает;
в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG
в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
@@ -496,19 +428,8 @@ high-rate флага. С карточки пользователя операт
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
кампания с несколькими сообщениями показывает их по очереди.
Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или
служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один
применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы
кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также
пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам,
владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает
**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих
системных уведомлений (тех.работы, сбои), а не для рекламы.
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты
на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько
держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между
сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она
остаётся простой (без цветов, никогда не срочная).
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
всегда остаётся хотя бы одно сообщение.
+3 -13
View File
@@ -19,15 +19,8 @@ tests or touching CI.
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/copy/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted).
- **Render sidecar**`renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG
(`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout,
notation) stay unit-tested in `ui/` — the sidecar test guards only the
skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across
hosts).
invitations section, the stats screen, profile editing, and the GCG export's
finished-only visibility.
- **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
@@ -69,10 +62,7 @@ tests or touching CI.
content and block-visibility rules, the nudge turn/rate-limit rules, the
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
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
email, expiry and attempt-cap) with a fixture mailer. It also covers the
**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,
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
+10 -48
View File
@@ -214,7 +214,8 @@ e2e and the screenshots.
**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
game with "multiple words per turn" off) centres a **"One word per turn"** label between
those two icons; standard games show no label. Each **opponent**'s card also gains two
those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that
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,
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
@@ -315,19 +316,6 @@ left. A **viewport size change** (e.g. a portrait↔landscape rotation) re-measu
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
and a long message does not scroll.
**Per-campaign colours.** A campaign may override the strip's colours (background, text, link). The
engine carries the current message's campaign colours to the host, and `AdBanner` resolves them for
the **rendered theme** (`lib/bannerColors`: dark ← dark-set ?? all-set, light ← all-set) and applies
them as inline CSS variables scoped to `.ad` (`--ad-bg`, `--text-muted`, `--accent`, and a derived
`--ad-border`) — so an override never leaks past the strip, and a campaign with no override keeps the
neutral tokens. The border is computed from the background in JS (a luminance-aware nudge toward
black/white — no CSS `color-mix`, which the old Android WebView floor lacks), matching the admin
console's live preview. The resolution re-runs when the theme flips (a `[data-theme]` /
`prefers-color-scheme` observer), so switching light↔dark repaints an overridden strip at once. An
**urgent** campaign has no client-specific styling — it simply arrives (with its colours) as the only
campaign in the feed; the preempt-and-show-everyone behaviour is entirely server-side (ARCHITECTURE
§10).
## Result / status iconography (`lib/result.ts`)
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
@@ -416,41 +404,15 @@ 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
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
Safari.
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); the 📤 in the history header appears
only once the game is finished — never in an honest-AI game (throwaway practice) — and
opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
there, since that chain is all bridge calls needing no user activation (a popup callback
must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's
own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal
click's activation; VK has no native chooser; the accent button leads with the image,
both options need the connection). Both formats mint the same signed relative link
(`game.export_url`), resolved against the app's own origin, then delivered per platform
(each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview
shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS
`VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose
DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save
works) and the GCG to the clipboard; the VK desktop iframe plain anchor downloads; a
mobile browser the OS share sheet (the proven fetch-then-share pattern); a desktop
browser an anchor download. A legacy Telegram
client without `downloadFile` keeps the old GCG clipboard copy, hides the image option
and stays on the app modal. Confirming a resign reveals the full board: it closes the
history drawer (portrait) and zooms the board out.
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
executes; the browser app no longer draws it): always the light palette (pinned constants
mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left,
value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is
the scoresheet: per-seat name over the final score (🏆 by the winner), one
fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down =
column-first `H8`), the main word, points right-aligned; extra words of a multi-word play
on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a
closing muted ± row for the endgame rack settlement when present. Footer:
`hostname · finish date` in the **device** locale. The scoresheet's height drives the
board side (never below its minimum): a long game stretches the board, never the
typography, so the image has no dead space.
- **History / GCG**: 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
header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app
WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download —
and appears only once the game is finished — and
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
it closes the history drawer (portrait) and zooms the board out.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
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
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
row is turn-driven: on your turn the message field shows until your one allowed message is

Some files were not shown because too many files have changed in this diff Show More