Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57ff2d03f8 | |||
| 3a85f64726 | |||
| 00bb66ba0f | |||
| a2c79c149a | |||
| 061366da5a | |||
| d19609f87d | |||
| 51147a1429 | |||
| a9d0986e74 | |||
| d61ba68e9b | |||
| 6db9178449 | |||
| ac383880b7 | |||
| 16349f5ad9 | |||
| 101a3f118c | |||
| 400b6ac2a5 | |||
| fb7490f1df | |||
| c1805e5b7c | |||
| 45957bdcd6 | |||
| fcedadcb5b | |||
| 2feb638329 | |||
| 4dfedd02a3 | |||
| 829e29a726 | |||
| 44117e906c | |||
| c90331b189 | |||
| 399508f2f0 | |||
| 0dfecc2af7 | |||
| 446ea2ac45 | |||
| 01a9249002 | |||
| 7dcd62fdd7 | |||
| d67e582c03 | |||
| 75fe07865a | |||
| 597e200f37 | |||
| 3ef18d33b6 | |||
| c8601c0115 | |||
| a0021d1994 | |||
| 4b4dcab9b6 | |||
| 7f85362288 | |||
| 72e9b600b0 | |||
| 0eefbfd6a4 | |||
| c680e695d3 | |||
| 2c465c01d2 | |||
| 60faa4f064 | |||
| 854c4b3005 | |||
| ec1bdfca00 | |||
| 70f0f9e36a | |||
| 55f6176538 | |||
| 8d2cd97e17 | |||
| f1a12c2f44 | |||
| 4cc37e5760 | |||
| 3faca690dd | |||
| c2a8426b74 | |||
| 251c7af3f6 | |||
| cabcd94d92 | |||
| aa2290b7b4 | |||
| fcde7d3db6 | |||
| d889edfdb9 | |||
| 52e6378f40 | |||
| 12af378b18 | |||
| 1598646021 | |||
| 710ab06333 | |||
| 61c7da271c | |||
| 029aa2d4cc | |||
| 3f4792a39b | |||
| 150660819a | |||
| 912096a0f1 | |||
| e4fc7f033d | |||
| b918217497 | |||
| a3eb4719de | |||
| 3a823ca7ef | |||
| 76e7916ff6 | |||
| 54af644429 | |||
| 356bf1a5ba | |||
| 1dca6741f1 | |||
| 77a18a3cc1 | |||
| 5804f7266e | |||
| 65f2c87a74 | |||
| 409462fc09 | |||
| 762155a55e | |||
| 25d80bc31d | |||
| d5b4bba018 | |||
| 638c147cc0 | |||
| c702f1bdac | |||
| 01d02fcef6 | |||
| 9e0f929e40 | |||
| a29e00ee13 | |||
| 2207ac6132 | |||
| 3877b23894 | |||
| d5fbaa3034 | |||
| 16a4431158 | |||
| 422e3ccc2a | |||
| 946420db93 | |||
| a0eacf3011 | |||
| 622d3965a7 | |||
| 2ab01ed8f7 | |||
| 1ed624eaf1 | |||
| f3e2a6822b | |||
| 65063621a9 | |||
| 4e169c368d | |||
| aec915d5c1 | |||
| 5f574a765d | |||
| db17287113 | |||
| c864147982 | |||
| 2e8fa83814 | |||
| 5f87b3fa43 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
+99
-15
@@ -26,12 +26,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [development]
|
branches: [development]
|
||||||
|
|
||||||
# The dictionary release the test suite validates against — the current
|
# The dictionary release. One Gitea variable is the single source of truth: the
|
||||||
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
# test suite validates against it here (inherited by the unit/integration jobs) and
|
||||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
|
||||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
# is one edit (the variable). See deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.3.1
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
@@ -73,6 +73,9 @@ jobs:
|
|||||||
go=false; ui=false
|
go=false; ui=false
|
||||||
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
||||||
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
||||||
|
# The render sidecar bundles ui/src/lib, so its dir rides the ui lane (the
|
||||||
|
# deploy's compose build picks it up either way).
|
||||||
|
if echo "$files" | grep -qE '^renderer/'; then ui=true; fi
|
||||||
# A workflow or deploy change re-runs everything as a safety net.
|
# A workflow or deploy change re-runs everything as a safety net.
|
||||||
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
||||||
else
|
else
|
||||||
@@ -199,6 +202,14 @@ jobs:
|
|||||||
- name: Bundle-size budget
|
- name: Bundle-size budget
|
||||||
run: node scripts/bundle-size.mjs
|
run: node scripts/bundle-size.mjs
|
||||||
|
|
||||||
|
# The render sidecar executes the shared ui/src/lib/gameimage.ts on skia-canvas;
|
||||||
|
# its smoke test guards the bundling + skia seam (docs/TESTING.md).
|
||||||
|
- name: Render sidecar test
|
||||||
|
working-directory: renderer
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm test
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: pnpm exec playwright install chromium webkit
|
run: pnpm exec playwright install chromium webkit
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -318,11 +329,42 @@ jobs:
|
|||||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
# 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.
|
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_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 }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
# 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_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
@@ -335,11 +377,16 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# 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.
|
||||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
||||||
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
||||||
run: |
|
run: |
|
||||||
# Seed the config files to a stable host path. The runner checks out into
|
# Seed the config files to a stable host path. The runner checks out into
|
||||||
@@ -350,8 +397,34 @@ jobs:
|
|||||||
conf="$HOME/.scrabble-deploy"
|
conf="$HOME/.scrabble-deploy"
|
||||||
rm -rf "$conf"
|
rm -rf "$conf"
|
||||||
mkdir -p "$conf"
|
mkdir -p "$conf"
|
||||||
cp -r caddy otelcol prometheus tempo grafana "$conf"/
|
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
|
||||||
export SCRABBLE_CONFIG_DIR="$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
|
# 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
|
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
|
||||||
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
|
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
|
||||||
@@ -364,12 +437,23 @@ jobs:
|
|||||||
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
# 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.
|
# main host omits both. Without the profile they would not start here.
|
||||||
docker compose --ansi never --profile telegram-local build --progress plain
|
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
|
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`
|
# 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
|
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
||||||
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
|
||||||
# pick up the fresh config.
|
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
|
||||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
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"
|
||||||
|
|
||||||
- name: Probe the landing, gateway and backend
|
- name: Probe the landing, gateway and backend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -40,11 +40,22 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
# 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).
|
||||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
# 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 }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -58,10 +69,15 @@ jobs:
|
|||||||
working-directory: deploy
|
working-directory: deploy
|
||||||
run: |
|
run: |
|
||||||
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
||||||
# The four main-stack images via compose (reuses the build args, incl. VERSION);
|
# 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 bot separately, since it is profiled out of the prod compose.
|
# the bot separately, since it is profiled out of the prod compose.
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator renderer
|
||||||
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
|
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
|
||||||
docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
|
docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
|
||||||
|
|
||||||
@@ -82,18 +98,45 @@ jobs:
|
|||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
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 }}
|
||||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
|
||||||
|
# deploy/write-prod-env.sh, not stored variables.
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -121,25 +164,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-main
|
mkdir -p stage/certs-main
|
||||||
cat > stage/env.sh <<EOF
|
# Shared with prod-rollback so the two paths render an identical runtime env.
|
||||||
export REGISTRY='$REGISTRY'
|
APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
|
||||||
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_CA" > stage/certs-main/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.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
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
||||||
@@ -150,7 +176,7 @@ jobs:
|
|||||||
ssh_main 'mkdir -p /opt/scrabble/compose'
|
ssh_main 'mkdir -p /opt/scrabble/compose'
|
||||||
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
|
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
|
||||||
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
|
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
|
||||||
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
|
tar -C deploy -czf - caddy otelcol prometheus tempo grafana blackbox \
|
||||||
| ssh_main 'tar -C /opt/scrabble -xzf -'
|
| ssh_main 'tar -C /opt/scrabble -xzf -'
|
||||||
tar -C stage -czf - certs-main \
|
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 -'
|
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
|
||||||
@@ -178,7 +204,8 @@ jobs:
|
|||||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# 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_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
@@ -195,20 +222,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-bot
|
mkdir -p stage/certs-bot
|
||||||
cat > stage/env.bot.sh <<EOF
|
# Shared with prod-rollback so the two paths render an identical bot env.
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||||
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_CA" > stage/certs-bot/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.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
|
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
||||||
|
|||||||
@@ -51,13 +51,32 @@ jobs:
|
|||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
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 }}
|
||||||
INPUT_TARGET: ${{ inputs.target_version }}
|
INPUT_TARGET: ${{ inputs.target_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -89,24 +108,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-main
|
mkdir -p stage/certs-main
|
||||||
cat > stage/env.sh <<EOF
|
# Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
|
||||||
export REGISTRY='$REGISTRY'
|
# runtime env (not a subset), so email / VK login / Grafana alerts survive it.
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
|
||||||
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_CA" > stage/certs-main/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.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
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
||||||
@@ -147,9 +151,11 @@ jobs:
|
|||||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# 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_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_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_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
||||||
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
steps:
|
steps:
|
||||||
@@ -163,19 +169,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-bot
|
mkdir -p stage/certs-bot
|
||||||
cat > stage/env.bot.sh <<EOF
|
# Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||||
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_CA" > stage/certs-bot/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.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
|
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
||||||
|
|||||||
@@ -125,9 +125,10 @@ gateway/ # module scrabble/gateway: Connect-RPC edge, embeds
|
|||||||
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
||||||
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
||||||
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
||||||
|
renderer/ # image-render sidecar (Node + skia-canvas): runs ui/src/lib/gameimage.ts server-side for the finished-game PNG export
|
||||||
loadtest/ # module scrabble/loadtest: the load/stress harness
|
loadtest/ # module scrabble/loadtest: the load/stress harness
|
||||||
docs/ .gitea/workflows/ CLAUDE.md README.md
|
docs/ .gitea/workflows/ CLAUDE.md README.md
|
||||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile renderer/Dockerfile # multi-stage distroless (renderer: node:22-slim + skia-canvas + fonts); gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
||||||
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
|
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -143,6 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
|||||||
|
|
||||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
||||||
pnpm start # UI mock mode: lobby -> game, no backend
|
pnpm start # UI mock mode: lobby -> game, no backend
|
||||||
|
cd renderer && pnpm install && pnpm test # image-render sidecar (bundles ui/src/lib/gameimage.ts, skia smoke)
|
||||||
|
|
||||||
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
||||||
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Erudit — site icons + Open Graph card
|
||||||
|
|
||||||
|
The favicon set and the `og:image` link-preview card for the public landing
|
||||||
|
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
|
||||||
|
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
|
||||||
|
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
|
||||||
|
|
||||||
|
| Output (committed to `ui/public/`) | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
|
||||||
|
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
|
||||||
|
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
|
||||||
|
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
|
||||||
|
character) from LiberationSans (Arial-metric, the game's font stack) with their
|
||||||
|
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
|
||||||
|
plain SVG from those outlines — no font is needed at generation time — writes
|
||||||
|
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
|
||||||
|
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
|
||||||
|
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
|
||||||
|
installed chromium version; the SVG sources are deterministic.
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
|
||||||
|
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
|
||||||
|
needs no extra packages**.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons
|
||||||
|
|
||||||
|
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
|
||||||
|
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
||||||
|
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
||||||
|
|
||||||
|
# 2. regenerate everything in ui/public/:
|
||||||
|
node build/generate.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
// Extract the glyph outlines the site icons and the og-image wordmark need from a
|
||||||
|
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
|
||||||
|
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
|
||||||
|
// plus the advance width so generate.js can lay out words without the font.
|
||||||
|
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
|
||||||
|
const opentype = require('opentype.js');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
|
||||||
|
const b = fs.readFileSync(FONT);
|
||||||
|
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
||||||
|
const FS = 1000; // em scale
|
||||||
|
|
||||||
|
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
|
||||||
|
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
|
||||||
|
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
|
||||||
|
|
||||||
|
function glyphData(ch) {
|
||||||
|
const g = font.charToGlyph(ch);
|
||||||
|
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
|
||||||
|
const contours = [];
|
||||||
|
let cur = null, prev = null;
|
||||||
|
for (const c of p.commands) {
|
||||||
|
if (c.type === 'M') {
|
||||||
|
if (cur) contours.push(cur);
|
||||||
|
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'L') {
|
||||||
|
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'C') {
|
||||||
|
cur[cur.length - 1].o = [c.x1, c.y1];
|
||||||
|
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'Q') {
|
||||||
|
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
|
||||||
|
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
|
||||||
|
cur[cur.length - 1].o = c1;
|
||||||
|
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'Z') {
|
||||||
|
if (cur && cur.length > 1) {
|
||||||
|
const last = cur[cur.length - 1], first = cur[0];
|
||||||
|
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
|
||||||
|
first.i = last.i; // fold the duplicate closing point into the first
|
||||||
|
cur.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) { contours.push(cur); cur = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) contours.push(cur);
|
||||||
|
|
||||||
|
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
||||||
|
const out = contours.map(ct => {
|
||||||
|
const v = [], i = [], o = [];
|
||||||
|
ct.forEach(pt => {
|
||||||
|
v.push(pt.v);
|
||||||
|
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
|
||||||
|
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
|
||||||
|
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
|
||||||
|
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
|
||||||
|
});
|
||||||
|
return { i, o, v, c: true };
|
||||||
|
});
|
||||||
|
const bbox = out.length
|
||||||
|
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
|
||||||
|
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
|
||||||
|
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
const glyphs = {};
|
||||||
|
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
|
||||||
|
|
||||||
|
const out = __dirname + '/glyphs.json';
|
||||||
|
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
|
||||||
|
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use strict';
|
||||||
|
// Site icons + the Open Graph card for the public landing, drawn from the same
|
||||||
|
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
|
||||||
|
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
|
||||||
|
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
|
||||||
|
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
|
||||||
|
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
|
||||||
|
// ui/public/:
|
||||||
|
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
|
||||||
|
// favicon.ico 32x32 PNG-in-ICO render of the same
|
||||||
|
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
|
||||||
|
// og-image.png 1200x630 card: tile + wordmark on the board green
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
|
||||||
|
const UI = path.resolve(__dirname, '../../../ui');
|
||||||
|
const OUT = path.join(UI, 'public');
|
||||||
|
|
||||||
|
// ---- palette (vk loader tile + app.css board tokens) ------------------------
|
||||||
|
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
|
||||||
|
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
|
||||||
|
|
||||||
|
// ---- glyph outlines -> SVG path data ----------------------------------------
|
||||||
|
const r2 = n => Math.round(n * 100) / 100;
|
||||||
|
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
|
||||||
|
function pathD(g, sc, tx, ty) {
|
||||||
|
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
|
||||||
|
const Z = [0, 0];
|
||||||
|
return g.contours.map(ct => {
|
||||||
|
const n = ct.v.length;
|
||||||
|
let d = `M${pt(ct.v[0], Z)}`;
|
||||||
|
for (let k = 1; k <= n; k++) {
|
||||||
|
const a = k - 1, b = k % n;
|
||||||
|
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
|
||||||
|
}
|
||||||
|
return d + 'Z';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
|
||||||
|
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
|
||||||
|
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
|
||||||
|
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
|
||||||
|
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
|
||||||
|
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
|
||||||
|
}
|
||||||
|
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
|
||||||
|
// the capital height (measured on «Э»). Returns the combined path + the width.
|
||||||
|
function textLine(str, capPx, x, y, colour) {
|
||||||
|
const sc = capPx / G.glyphs['Э'].bbox.h;
|
||||||
|
let d = '', w = 0;
|
||||||
|
for (const ch of str) {
|
||||||
|
const g = G.glyphs[ch];
|
||||||
|
if (g.contours.length) d += pathD(g, sc, x + w, y);
|
||||||
|
w += g.adv * sc;
|
||||||
|
}
|
||||||
|
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
|
||||||
|
}
|
||||||
|
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
|
||||||
|
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
|
||||||
|
function tile(cx, cy, size, withScore) {
|
||||||
|
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
|
||||||
|
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
|
||||||
|
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
|
||||||
|
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
||||||
|
|
||||||
|
// ---- favicon.svg (the committed vector master) -------------------------------
|
||||||
|
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
|
||||||
|
|
||||||
|
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
|
||||||
|
const appleTouch = svg(180, 180,
|
||||||
|
`<rect width="180" height="180" fill="${FACE}"/>` +
|
||||||
|
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
|
||||||
|
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
|
||||||
|
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
|
||||||
|
|
||||||
|
// ---- og-image: tile + wordmark, centred as one group on the board green ------
|
||||||
|
function ogImage() {
|
||||||
|
const W = 1200, H = 630, tileSize = 340, gap = 84;
|
||||||
|
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
|
||||||
|
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
|
||||||
|
const textW = Math.max(l1.width, l2.width);
|
||||||
|
const left = (W - (tileSize + gap + textW)) / 2;
|
||||||
|
const tx = left + tileSize + gap;
|
||||||
|
// Two baselines around the vertical centre; the tile centre sits between them.
|
||||||
|
const b1 = 295, b2 = 408;
|
||||||
|
const body =
|
||||||
|
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
|
||||||
|
tile(left + tileSize / 2, H / 2, tileSize, true) +
|
||||||
|
textLine('Эрудит', 112, tx, b1, TEXT).svg +
|
||||||
|
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
|
||||||
|
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
|
||||||
|
return svg(W, H, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
|
||||||
|
async function shoot(page, markup, w, h, transparent) {
|
||||||
|
await page.setViewportSize({ width: w, height: h });
|
||||||
|
await page.setContent(`<body style="margin:0">${markup}</body>`);
|
||||||
|
return page.screenshot({ omitBackground: transparent });
|
||||||
|
}
|
||||||
|
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
|
||||||
|
function icoFromPNG(png, sizePx) {
|
||||||
|
const h = Buffer.alloc(22);
|
||||||
|
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
|
||||||
|
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
|
||||||
|
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
|
||||||
|
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
|
||||||
|
return Buffer.concat([h, png]);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
|
||||||
|
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
|
||||||
|
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
|
||||||
|
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
|
||||||
|
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
|
||||||
|
await browser.close();
|
||||||
|
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
|
||||||
|
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
|
||||||
|
}
|
||||||
|
})();
|
||||||
File diff suppressed because one or more lines are too long
+7
-5
@@ -214,11 +214,13 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
|
|||||||
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
||||||
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
|
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
|
||||||
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
|
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
|
||||||
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
|
| `BACKEND_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). |
|
||||||
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
|
| `BACKEND_SMTP_PORT` | `587` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
| `BACKEND_SMTP_TLS` | — | Transport security: `ssl` (implicit TLS from connect) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); set it for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
||||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
| `BACKEND_SMTP_USERNAME` | — | SMTP AUTH user; empty relays without authentication. |
|
||||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
| `BACKEND_SMTP_PASSWORD` | — | SMTP AUTH password. |
|
||||||
|
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | From address. A deployed contour must use the prod domain (the relay only accepts its verified sender domain). |
|
||||||
|
| `BACKEND_PUBLIC_BASE_URL` | — | Canonical public origin (scheme + host) for links in the email. **Required when `BACKEND_SMTP_HOST` is set.** Never derived from a request Host header (anti-injection). |
|
||||||
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||||||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/accountmerge"
|
"scrabble/backend/internal/accountmerge"
|
||||||
|
"scrabble/backend/internal/adminalert"
|
||||||
"scrabble/backend/internal/ads"
|
"scrabble/backend/internal/ads"
|
||||||
"scrabble/backend/internal/banview"
|
"scrabble/backend/internal/banview"
|
||||||
"scrabble/backend/internal/config"
|
"scrabble/backend/internal/config"
|
||||||
@@ -33,6 +34,7 @@ import (
|
|||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -43,6 +45,10 @@ import (
|
|||||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||||
const telemetryShutdownTimeout = 5 * time.Second
|
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() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,6 +167,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.Duration("interval", cfg.GuestReapInterval),
|
zap.Duration("interval", cfg.GuestReapInterval),
|
||||||
zap.Duration("retention", cfg.GuestRetention))
|
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:
|
// 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
|
// 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.
|
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
|
||||||
@@ -173,7 +188,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
||||||
// so they are handed to the server (like the route groups) for the handlers.
|
// so they are handed to the server (like the route groups) for the handlers.
|
||||||
mailer := newMailer(cfg.SMTP, logger)
|
mailer := newMailer(cfg.SMTP, logger)
|
||||||
emails := account.NewEmailService(accounts, mailer)
|
emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL)
|
||||||
|
// Throttle confirm-code sends per recipient: at most one per minute and five per
|
||||||
|
// rolling hour, guarding against email bombing and the relay's own quota.
|
||||||
|
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
|
||||||
// Account linking & merge: the orchestrator over the account, merge and
|
// Account linking & merge: the orchestrator over the account, merge and
|
||||||
// session layers. Wired to the /api/v1/user/link REST surface below.
|
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||||
@@ -194,6 +212,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
||||||
feedbackSvc.SetNotifier(hub)
|
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 != "" {
|
||||||
|
// The alert digest carries no admin-console link on purpose — an admin URL must not
|
||||||
|
// travel in an email (a mail provider could cache or index it); see adminalert.New.
|
||||||
|
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, 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
|
// Robot opponent: provision its durable account pool (a hard startup
|
||||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||||
// substitutes a pooled robot for a missing human after the wait window.
|
// substitutes a pooled robot for a missing human after the wait window.
|
||||||
@@ -232,6 +260,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
// block and the banner admin console section.
|
// block and the banner admin console section.
|
||||||
adsSvc := ads.NewService(ads.NewStore(db))
|
adsSvc := ads.NewService(ads.NewStore(db))
|
||||||
|
|
||||||
|
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||||
|
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||||
|
var renderer *render.Client
|
||||||
|
if cfg.RendererURL != "" {
|
||||||
|
renderer = render.New(cfg.RendererURL)
|
||||||
|
}
|
||||||
|
|
||||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -253,6 +288,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
BanView: banView,
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/pressly/goose/v3 v3.27.1
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
github.com/testcontainers/testcontainers-go v0.42.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||||
|
github.com/wneessen/go-mail v0.7.3
|
||||||
go.opentelemetry.io/otel v1.43.0
|
go.opentelemetry.io/otel v1.43.0
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
||||||
@@ -109,7 +110,7 @@ require (
|
|||||||
golang.org/x/net v0.53.0 // indirect
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
google.golang.org/grpc v1.80.0
|
google.golang.org/grpc v1.80.0
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -279,6 +279,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
|
||||||
|
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
@@ -400,6 +402,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
|||||||
@@ -121,13 +121,44 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionEmail returns the account owning the email identity externalID, creating
|
// ProvisionEmail returns the account owning the email identity externalID, creating
|
||||||
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
|
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
|
||||||
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
|
// seeded into its time zone, language seeded from the client's UI language, and its
|
||||||
// and leaves an existing account untouched, so a returning user's saved zone is never
|
// display name seeded from the email's local part (so it is not left nameless). Like
|
||||||
// overwritten. The email account is created here (the code-request step), not at the
|
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
|
||||||
// later login, so this is where its zone is seeded.
|
// returning user's saved zone, language and name are never overwritten. The email account is
|
||||||
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
|
// created here (the code-request step), not at the later login, so this is where its
|
||||||
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
|
// 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 ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||||
@@ -239,6 +270,11 @@ type provisionSeed struct {
|
|||||||
preferredLanguage string
|
preferredLanguage string
|
||||||
displayName string
|
displayName string
|
||||||
timeZone string
|
timeZone string
|
||||||
|
// isGuest creates the account flagged is_guest. It is set for an email-login
|
||||||
|
// account, which stays a guest until the address is confirmed (so an abandoned,
|
||||||
|
// never-confirmed login is reaped and its address freed); confirming clears the
|
||||||
|
// flag. Platform identities (telegram/vk) are durable from creation.
|
||||||
|
isGuest bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedZone returns browserTZ when it is a well-formed zone to persist at account
|
// seedZone returns browserTZ when it is a well-formed zone to persist at account
|
||||||
@@ -447,8 +483,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
|||||||
tz = "UTC"
|
tz = "UTC"
|
||||||
}
|
}
|
||||||
insertAccount := table.Accounts.
|
insertAccount := table.Accounts.
|
||||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
|
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest).
|
||||||
VALUES(accountID, seed.displayName, lang, tz).
|
VALUES(accountID, seed.displayName, lang, tz, seed.isGuest).
|
||||||
RETURNING(table.Accounts.AllColumns)
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
var row model.Accounts
|
var row model.Accounts
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -26,6 +27,22 @@ const (
|
|||||||
emailCodeTTL = 15 * time.Minute
|
emailCodeTTL = 15 * time.Minute
|
||||||
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
|
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
|
||||||
emailCodeMaxAttempts = 5
|
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.
|
// Errors returned by the email confirm-code flow.
|
||||||
@@ -46,6 +63,12 @@ var (
|
|||||||
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
|
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
|
||||||
// ErrCodeMismatch is returned when the submitted code does not match.
|
// ErrCodeMismatch is returned when the submitted code does not match.
|
||||||
ErrCodeMismatch = errors.New("account: confirmation code does not match")
|
ErrCodeMismatch = errors.New("account: confirmation code does not match")
|
||||||
|
// ErrTooManyRequests is returned when confirm-code sends to an address are being
|
||||||
|
// requested too frequently (the resend cooldown or the rolling-hour cap).
|
||||||
|
ErrTooManyRequests = errors.New("account: too many code requests")
|
||||||
|
// 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
|
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
|
||||||
@@ -55,14 +78,82 @@ var (
|
|||||||
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
||||||
// and using an email as a login reuses this mechanism.
|
// and using an email as a login reuses this mechanism.
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
store *Store
|
store *Store
|
||||||
mailer Mailer
|
mailer Mailer
|
||||||
now func() time.Time
|
baseURL string
|
||||||
|
limiter *SendLimiter
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEmailService constructs an EmailService over store, sending via mailer.
|
// NewEmailService constructs an EmailService over store, sending via mailer. baseURL
|
||||||
func NewEmailService(store *Store, mailer Mailer) *EmailService {
|
// is the canonical public origin (scheme + host) used to build the one-tap confirm
|
||||||
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }}
|
// deeplink and the email footer landing link; an empty baseURL omits the deeplink
|
||||||
|
// (development / log mailer).
|
||||||
|
func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService {
|
||||||
|
return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are
|
||||||
|
// not throttled — production wires a limiter; tests leave it off.
|
||||||
|
func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l }
|
||||||
|
|
||||||
|
// allowSend reports whether a confirm-code send to email is permitted now, recording
|
||||||
|
// it when so. A nil limiter permits every send.
|
||||||
|
func (s *EmailService) allowSend(email string) bool {
|
||||||
|
return s.limiter == nil || s.limiter.Allow(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueCode generates a fresh confirm-code 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. omitLink drops the
|
||||||
|
// one-tap deeplink from the email (account deletion, or a login requested from an installed
|
||||||
|
// PWA). 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, omitLink bool) 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
|
||||||
|
}
|
||||||
|
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
|
||||||
|
// open in a separate browser whose minted session cannot reach the PWA), or for account
|
||||||
|
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
|
||||||
|
// in the app only, and ConfirmByToken refuses a delete token).
|
||||||
|
deeplink := s.confirmURL(token, locale)
|
||||||
|
if purpose == purposeDelete || omitLink {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
|
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
|
||||||
@@ -73,6 +164,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !s.allowSend(addr) {
|
||||||
|
return ErrTooManyRequests
|
||||||
|
}
|
||||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -83,16 +177,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
}
|
}
|
||||||
return ErrEmailTaken
|
return ErrEmailTaken
|
||||||
}
|
}
|
||||||
code, hash, err := generateCode()
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
subject := "Your Scrabble confirmation code"
|
|
||||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
|
||||||
return s.mailer.Send(ctx, addr, subject, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
||||||
@@ -123,36 +208,38 @@ 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 {
|
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||||
return Account{}, err
|
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)
|
return s.store.GetByID(ctx, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
||||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
|
||||||
// the unauthenticated email-login entry point and, unlike RequestCode,
|
// durable once the code is confirmed. It is the unauthenticated email-login entry
|
||||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
// point and, unlike RequestCode, does not refuse an already-confirmed email — that is
|
||||||
// login. The code is mailed to the address, so only its real owner can complete
|
// the ordinary returning-user login. The code is mailed to the address, so only its
|
||||||
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
|
// real owner can complete the login. On first contact browserTZ (the client's
|
||||||
// seeds the new account's time zone. It returns the target account id for the
|
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
|
||||||
// subsequent LoginWithCode.
|
// language. When pwa is set (the request came from an installed PWA) the login email omits the
|
||||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
|
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
|
||||||
|
// code is entered in the same window. It returns the target account id for the subsequent
|
||||||
|
// LoginWithCode.
|
||||||
|
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
|
||||||
addr, err := normalizeEmail(email)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
|
if !s.allowSend(addr) {
|
||||||
|
return uuid.UUID{}, ErrTooManyRequests
|
||||||
|
}
|
||||||
|
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
code, hash, err := generateCode()
|
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil {
|
||||||
if err != nil {
|
|
||||||
return uuid.UUID{}, err
|
|
||||||
}
|
|
||||||
if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
|
||||||
return uuid.UUID{}, err
|
|
||||||
}
|
|
||||||
subject := "Your Scrabble login code"
|
|
||||||
body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
|
||||||
if err := s.mailer.Send(ctx, addr, subject, body); err != nil {
|
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
return acc.ID, nil
|
return acc.ID, nil
|
||||||
@@ -191,9 +278,104 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
|
|||||||
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
|
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
|
||||||
return Account{}, err
|
return Account{}, err
|
||||||
}
|
}
|
||||||
|
if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
return s.store.GetByID(ctx, acc.ID)
|
return s.store.GetByID(ctx, acc.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// emailConfirmation is a pending confirm-code row in domain form.
|
||||||
type emailConfirmation struct {
|
type emailConfirmation struct {
|
||||||
id uuid.UUID
|
id uuid.UUID
|
||||||
@@ -222,9 +404,30 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
|
|||||||
return row.AccountID, true, nil
|
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
|
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
||||||
// inserts a fresh one, inside one transaction.
|
// inserts a fresh one, inside one transaction.
|
||||||
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
|
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("account: new confirmation id: %w", err)
|
return fmt.Errorf("account: new confirmation id: %w", err)
|
||||||
@@ -241,7 +444,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U
|
|||||||
ins := table.EmailConfirmations.INSERT(
|
ins := table.EmailConfirmations.INSERT(
|
||||||
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
|
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
|
||||||
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
|
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
|
||||||
).VALUES(id, accountID, email, codeHash, expiresAt)
|
table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
|
||||||
|
).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
|
||||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("insert confirmation: %w", err)
|
return fmt.Errorf("insert confirmation: %w", err)
|
||||||
}
|
}
|
||||||
@@ -274,6 +478,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU
|
|||||||
}, nil
|
}, 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.
|
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
|
||||||
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
|
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
|
||||||
stmt := table.EmailConfirmations.
|
stmt := table.EmailConfirmations.
|
||||||
@@ -320,6 +572,69 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
|
|||||||
return nil
|
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
|
// confirmEmailLogin consumes the login code and marks the existing email
|
||||||
// identity confirmed, inside one transaction. The identity already exists (a
|
// identity confirmed, inside one transaction. The identity already exists (a
|
||||||
// login provisioned it), so this updates rather than inserts and is idempotent
|
// login provisioned it), so this updates rather than inserts and is idempotent
|
||||||
@@ -367,6 +682,18 @@ func generateCode() (code, hash string, err error) {
|
|||||||
return code, hashCode(code), nil
|
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.
|
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
|
||||||
func hashCode(code string) string {
|
func hashCode(code string) string {
|
||||||
sum := sha256.Sum256([]byte(code))
|
sum := sha256.Sum256([]byte(code))
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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}}
|
||||||
|
`))
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package account
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +17,68 @@ import (
|
|||||||
// belongs to another account; the caller turns it into a merge.
|
// belongs to another account; the caller turns it into a merge.
|
||||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
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,
|
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||||
// (taken or already-confirmed): possession of the address is the authorization for
|
// (taken or already-confirmed): possession of the address is the authorization for
|
||||||
@@ -26,16 +89,10 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
code, hash, err := generateCode()
|
if !s.allowSend(addr) {
|
||||||
if err != nil {
|
return ErrTooManyRequests
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
|
||||||
return err
|
|
||||||
}
|
|
||||||
subject := "Your Scrabble confirmation code"
|
|
||||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
|
||||||
return s.mailer.Send(ctx, addr, subject, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||||
@@ -70,6 +127,100 @@ func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
return accountID, true, nil
|
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), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
|
||||||
// addr), counting a wrong attempt. It returns the confirmation on success.
|
// 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) {
|
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
|
||||||
|
|||||||
@@ -3,33 +3,99 @@ package account
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"strconv"
|
||||||
"net/smtp"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wneessen/go-mail"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Message is a transactional email to send through a Mailer. Text is the
|
||||||
|
// required plain-text body and doubles as the multipart/alternative fallback;
|
||||||
|
// HTML, when non-empty, is the preferred body a capable client renders instead.
|
||||||
|
type Message struct {
|
||||||
|
// To 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
|
// Mailer delivers a transactional email. It is the seam behind which the email
|
||||||
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
|
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
|
||||||
// fixture (see docs/TESTING.md: no real network in tests). The context is offered
|
// fixture (see docs/TESTING.md: no real network in tests). The context bounds the
|
||||||
// for cancellation; the standard-library SMTP implementation sends synchronously
|
// delivery and is honoured by the SMTP implementation.
|
||||||
// and ignores it.
|
|
||||||
type Mailer interface {
|
type Mailer interface {
|
||||||
Send(ctx context.Context, to, subject, body string) error
|
Send(ctx context.Context, msg Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
||||||
// instead, so a deployment without a relay still runs (the code lands in the log).
|
// instead, so a deployment without a relay still runs (the code lands in the log).
|
||||||
|
// TLS is always used and no client certificate is required — only the server
|
||||||
|
// certificate is validated against the system roots.
|
||||||
type SMTPConfig struct {
|
type SMTPConfig struct {
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
From string
|
From string
|
||||||
|
// TLS selects the transport security: "ssl" for implicit TLS from connect, or
|
||||||
|
// "starttls" to upgrade a plaintext connection. Empty derives the mode from the
|
||||||
|
// port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on
|
||||||
|
// a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS).
|
||||||
|
TLS string
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
|
const (
|
||||||
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
|
// SMTP transport-security modes for SMTPConfig.TLS.
|
||||||
|
smtpTLSImplicit = "ssl"
|
||||||
|
smtpTLSSTARTTLS = "starttls"
|
||||||
|
// smtpDialTimeout bounds a single relay connect-and-send. The confirm-code send
|
||||||
|
// is synchronous on the request path, so an unreachable relay must fail fast
|
||||||
|
// rather than hold the request open.
|
||||||
|
smtpDialTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// tlsMode resolves the transport-security mode for the relay: the explicitly
|
||||||
|
// configured SMTPConfig.TLS, or — when unset — implicit TLS on the conventional SSL
|
||||||
|
// port 465 and STARTTLS on any other port.
|
||||||
|
func (cfg SMTPConfig) tlsMode(port int) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cfg.TLS)) {
|
||||||
|
case smtpTLSImplicit, "tls":
|
||||||
|
return smtpTLSImplicit
|
||||||
|
case smtpTLSSTARTTLS:
|
||||||
|
return smtpTLSSTARTTLS
|
||||||
|
}
|
||||||
|
if port == 465 {
|
||||||
|
return smtpTLSImplicit
|
||||||
|
}
|
||||||
|
return smtpTLSSTARTTLS
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTPMailer sends mail through an SMTP relay using go-mail. When a username is
|
||||||
|
// set it authenticates, auto-discovering the strongest mechanism the relay
|
||||||
|
// advertises; otherwise it relays unauthenticated.
|
||||||
type SMTPMailer struct {
|
type SMTPMailer struct {
|
||||||
cfg SMTPConfig
|
cfg SMTPConfig
|
||||||
}
|
}
|
||||||
@@ -39,29 +105,55 @@ func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
|
|||||||
return SMTPMailer{cfg: cfg}
|
return SMTPMailer{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send delivers a plain-text UTF-8 message to to via the configured relay.
|
// Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
|
||||||
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
|
// is set the message is multipart/alternative (plain text plus HTML); otherwise
|
||||||
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
|
// it is plain text only.
|
||||||
var auth smtp.Auth
|
func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
|
||||||
if m.cfg.Username != "" {
|
port, err := strconv.Atoi(m.cfg.Port)
|
||||||
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
if err != nil {
|
||||||
|
return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err)
|
||||||
}
|
}
|
||||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil {
|
opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)}
|
||||||
return fmt.Errorf("account: send mail to %s: %w", to, err)
|
if m.cfg.tlsMode(port) == smtpTLSImplicit {
|
||||||
|
opts = append(opts, mail.WithSSL())
|
||||||
|
} else {
|
||||||
|
opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory))
|
||||||
|
}
|
||||||
|
if m.cfg.Username != "" {
|
||||||
|
opts = append(opts,
|
||||||
|
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
|
||||||
|
mail.WithUsername(m.cfg.Username),
|
||||||
|
mail.WithPassword(m.cfg.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
client, err := mail.NewClient(m.cfg.Host, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("account: build mail client: %w", err)
|
||||||
|
}
|
||||||
|
out := mail.NewMsg()
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// message renders a minimal RFC 5322 plain-text email.
|
|
||||||
func message(from, to, subject, body string) []byte {
|
|
||||||
return []byte("From: " + from + "\r\n" +
|
|
||||||
"To: " + to + "\r\n" +
|
|
||||||
"Subject: " + subject + "\r\n" +
|
|
||||||
"MIME-Version: 1.0\r\n" +
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
|
||||||
"\r\n" + body + "\r\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogMailer logs the message instead of sending it. It is the default when no
|
// LogMailer logs the message instead of sending it. It is the default when no
|
||||||
// SMTP relay is configured and is intended for development only: it logs the body,
|
// SMTP relay is configured and is intended for development only: it logs the body,
|
||||||
// which carries the confirm-code, so it must not be used in production.
|
// which carries the confirm-code, so it must not be used in production.
|
||||||
@@ -74,11 +166,12 @@ func NewLogMailer(log *zap.Logger) LogMailer {
|
|||||||
return LogMailer{log: log}
|
return LogMailer{log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send logs the message at info level and reports success.
|
// Send logs the message at info level and reports success. It logs the plain-text
|
||||||
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
|
// body only (which carries the confirm-code); the HTML alternative is omitted.
|
||||||
|
func (m LogMailer) Send(_ context.Context, msg Message) error {
|
||||||
if m.log != nil {
|
if m.log != nil {
|
||||||
m.log.Info("email not sent (log mailer)",
|
m.log.Info("email not sent (log mailer)",
|
||||||
zap.String("to", to), zap.String("subject", subject), zap.String("body", body))
|
zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendLimiter throttles confirm-code sends per recipient address: it enforces a
|
||||||
|
// minimum cooldown between two sends and a cap over a rolling hour. It guards against
|
||||||
|
// email bombing and protects the relay's own quota. State is in-memory (per process,
|
||||||
|
// reset on restart) and keyed by the normalised recipient address, which is adequate
|
||||||
|
// for the single-instance backend. Safe for concurrent use.
|
||||||
|
type SendLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cooldown time.Duration
|
||||||
|
perHour int
|
||||||
|
now func() time.Time
|
||||||
|
sends map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSendLimiter returns a SendLimiter allowing at most one send per cooldown and at
|
||||||
|
// most perHour sends over any rolling hour, to the same recipient.
|
||||||
|
func NewSendLimiter(cooldown time.Duration, perHour int) *SendLimiter {
|
||||||
|
return &SendLimiter{
|
||||||
|
cooldown: cooldown,
|
||||||
|
perHour: perHour,
|
||||||
|
now: func() time.Time { return time.Now() },
|
||||||
|
sends: make(map[string][]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether a send to key is permitted now, recording the send when it is.
|
||||||
|
// It is denied when the last send was within the cooldown or the rolling-hour cap is
|
||||||
|
// already reached.
|
||||||
|
func (l *SendLimiter) Allow(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := l.now()
|
||||||
|
cutoff := now.Add(-time.Hour)
|
||||||
|
kept := l.sends[key][:0]
|
||||||
|
for _, t := range l.sends[key] {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
kept = append(kept, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := len(kept); n > 0 && now.Sub(kept[n-1]) < l.cooldown {
|
||||||
|
l.set(key, kept)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(kept) >= l.perHour {
|
||||||
|
l.set(key, kept)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l.set(key, append(kept, now))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// set stores the retained send times for key, dropping the entry entirely once empty
|
||||||
|
// so the map stays bounded to recipients active within the last hour.
|
||||||
|
func (l *SendLimiter) set(key string, times []time.Time) {
|
||||||
|
if len(times) == 0 {
|
||||||
|
delete(l.sends, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.sends[key] = times
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSendLimiter checks the per-recipient cooldown and the rolling-hour cap, and
|
||||||
|
// that recipients are throttled independently.
|
||||||
|
func TestSendLimiter(t *testing.T) {
|
||||||
|
base := time.Now()
|
||||||
|
now := base
|
||||||
|
l := NewSendLimiter(time.Minute, 3)
|
||||||
|
l.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("send 1 should be allowed")
|
||||||
|
}
|
||||||
|
if l.Allow("a") {
|
||||||
|
t.Fatal("immediate resend must be blocked by the cooldown")
|
||||||
|
}
|
||||||
|
if !l.Allow("b") {
|
||||||
|
t.Fatal("a different recipient is independent")
|
||||||
|
}
|
||||||
|
|
||||||
|
now = base.Add(time.Minute)
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("send 2 after the cooldown should be allowed")
|
||||||
|
}
|
||||||
|
now = base.Add(2 * time.Minute)
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("send 3 should be allowed")
|
||||||
|
}
|
||||||
|
now = base.Add(3 * time.Minute)
|
||||||
|
if l.Allow("a") {
|
||||||
|
t.Fatal("send 4 within the hour must be blocked by the cap")
|
||||||
|
}
|
||||||
|
|
||||||
|
now = base.Add(time.Hour + time.Minute)
|
||||||
|
if !l.Allow("a") {
|
||||||
|
t.Fatal("after the rolling hour the cap resets")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ type UserListItem struct {
|
|||||||
PreferredLanguage string
|
PreferredLanguage string
|
||||||
IsGuest bool
|
IsGuest bool
|
||||||
IsRobot 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
|
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
||||||
// as a badge in the console list.
|
// as a badge in the console list.
|
||||||
FlaggedHighRateAt time.Time
|
FlaggedHighRateAt time.Time
|
||||||
@@ -26,13 +29,17 @@ type UserListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
||||||
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
// non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them);
|
||||||
// one char) matched case-insensitively against the display name / any identity's external
|
// NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched
|
||||||
// id. An empty mask means no filter on that field.
|
// 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.
|
||||||
type UserFilter struct {
|
type UserFilter struct {
|
||||||
Robots bool
|
Robots bool
|
||||||
|
Deleted bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
|
EmailExact string
|
||||||
}
|
}
|
||||||
|
|
||||||
// robotExists is the correlated subquery testing whether account a is a robot.
|
// robotExists is the correlated subquery testing whether account a is a robot.
|
||||||
@@ -51,17 +58,42 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
|
|||||||
return ok, nil
|
return ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// userListWhere builds the shared WHERE clause and its positional args (from $1).
|
// 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.
|
||||||
func userListWhere(f UserFilter) (string, []any) {
|
func userListWhere(f UserFilter) (string, []any) {
|
||||||
args := []any{f.Robots}
|
name := LikePattern(f.NameMask)
|
||||||
where := robotExists + ` = $1`
|
ext := LikePattern(f.ExternalIDMask)
|
||||||
if name := LikePattern(f.NameMask); name != "" {
|
email := strings.ToLower(strings.TrimSpace(f.EmailExact))
|
||||||
args = append(args, name)
|
searching := name != "" || ext != "" || email != ""
|
||||||
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
|
||||||
|
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 ext := LikePattern(f.ExternalIDMask); ext != "" {
|
if 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))
|
||||||
|
}
|
||||||
|
if ext != "" {
|
||||||
args = append(args, 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 '\')`, 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 '\') 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))
|
||||||
}
|
}
|
||||||
return where, args
|
return where, args
|
||||||
}
|
}
|
||||||
@@ -69,7 +101,7 @@ func userListWhere(f UserFilter) (string, []any) {
|
|||||||
// ListUsers returns the filtered admin user list, newest first, paginated.
|
// ListUsers returns the filtered admin user list, newest first, paginated.
|
||||||
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
||||||
where, args := userListWhere(f)
|
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
|
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
|
||||||
FROM backend.accounts a WHERE ` + where +
|
FROM backend.accounts a WHERE ` + where +
|
||||||
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
@@ -82,7 +114,7 @@ FROM backend.accounts a WHERE ` + where +
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it UserListItem
|
var it UserListItem
|
||||||
var flagged sql.NullTime
|
var flagged sql.NullTime
|
||||||
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
|
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil {
|
||||||
return nil, fmt.Errorf("account: scan user: %w", err)
|
return nil, fmt.Errorf("account: scan user: %w", err)
|
||||||
}
|
}
|
||||||
if flagged.Valid {
|
if flagged.Valid {
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
// without taking a dependency on the game package.
|
// without taking a dependency on the game package.
|
||||||
const statusActive = "active"
|
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.
|
// Friendship statuses, highest precedence first, mirroring internal/social.
|
||||||
const (
|
const (
|
||||||
friendAccepted = "accepted"
|
friendAccepted = "accepted"
|
||||||
@@ -75,6 +80,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
|
|||||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
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 {
|
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
||||||
return fmt.Errorf("accountmerge: identities: %w", err)
|
return fmt.Errorf("accountmerge: identities: %w", err)
|
||||||
}
|
}
|
||||||
@@ -300,6 +308,77 @@ func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col pos
|
|||||||
return err
|
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).
|
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
||||||
func friendRank(status string) int {
|
func friendRank(status string) int {
|
||||||
switch status {
|
switch status {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// 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
|
||||||
|
clock func() time.Time
|
||||||
|
log *zap.Logger
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
|
||||||
|
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
|
||||||
|
// digest deliberately carries no admin-console link — an admin URL must never travel in an
|
||||||
|
// email, where a mail provider could cache or index it.
|
||||||
|
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
|
||||||
|
if log == nil {
|
||||||
|
log = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &Notifier{
|
||||||
|
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
|
||||||
|
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, ", ")
|
||||||
|
// No admin-console link in the body: an admin URL must never travel in an email (a mail
|
||||||
|
// provider could cache or index it). The operator opens the console directly.
|
||||||
|
text := summary + "."
|
||||||
|
return account.Message{
|
||||||
|
From: n.from,
|
||||||
|
To: n.to,
|
||||||
|
Subject: "Erudit — " + summary,
|
||||||
|
Text: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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", 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)
|
||||||
|
}
|
||||||
|
// The digest must never carry an admin-console link — an admin URL in an email is a leak
|
||||||
|
// (mail providers cache/index it).
|
||||||
|
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
|
||||||
|
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,3 +193,24 @@ 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 { 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 { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||||
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
.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,10 +11,72 @@
|
|||||||
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
<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>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
||||||
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</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}}
|
{{end}}
|
||||||
<div><button type="submit">Save</button></div>
|
<div><button type="submit">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{if not .IsDefault}}
|
{{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?')">
|
<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>
|
<button type="submit" class="danger">Delete campaign</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -101,6 +101,29 @@
|
|||||||
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 & 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>
|
||||||
<section class="panel"><h2>Friends</h2>
|
<section class="panel"><h2>Friends</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<nav class="subnav">
|
<nav class="subnav">
|
||||||
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
|
<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?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
||||||
</nav>
|
</nav>
|
||||||
<form class="form" method="get" action="/_gm/users">
|
<form class="form" method="get" action="/_gm/users">
|
||||||
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
|
||||||
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id 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>
|
<button type="submit">Filter</button>
|
||||||
</form>
|
</form>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</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>{{.Kind}}</td>
|
<td>{{.Kind}}</td>
|
||||||
<td>{{.Language}}</td>
|
<td>{{.Language}}</td>
|
||||||
<td>{{.CreatedAt}}</td>
|
<td>{{.CreatedAt}}</td>
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ type UsersView struct {
|
|||||||
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
||||||
// percent-encoded again by the contextual escaper.
|
// percent-encoded again by the contextual escaper.
|
||||||
Robots bool
|
Robots bool
|
||||||
|
Deleted bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
|
EmailExact string
|
||||||
FilterQuery template.URL
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ type UserRow struct {
|
|||||||
Kind string
|
Kind string
|
||||||
Language string
|
Language string
|
||||||
Guest bool
|
Guest bool
|
||||||
|
Deleted bool
|
||||||
FlaggedHighRate bool
|
FlaggedHighRate bool
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
HasMoveStats bool
|
HasMoveStats bool
|
||||||
@@ -150,6 +153,15 @@ type UserDetailView struct {
|
|||||||
// MergedInto is the primary account id when this account has been retired by a
|
// MergedInto is the primary account id when this account has been retired by a
|
||||||
// merge, or empty for a live account.
|
// merge, or empty for a live account.
|
||||||
MergedInto string
|
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,
|
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||||
// empty for an unflagged account; the card shows it with the Clear action.
|
// empty for an unflagged account; the card shows it with the Clear action.
|
||||||
FlaggedHighRateAt string
|
FlaggedHighRateAt string
|
||||||
@@ -161,7 +173,9 @@ type UserDetailView struct {
|
|||||||
HasStats bool
|
HasStats bool
|
||||||
Stats StatsRow
|
Stats StatsRow
|
||||||
Identities []IdentityRow
|
Identities []IdentityRow
|
||||||
Games []GameRow
|
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
|
||||||
|
HasEmail bool
|
||||||
|
Games []GameRow
|
||||||
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
// 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
|
// 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).
|
// user id with a link to the VK profile (there is no VK messaging to drive).
|
||||||
@@ -234,6 +248,17 @@ type IdentityRow struct {
|
|||||||
CreatedAt string
|
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.
|
// GameRow is one game row in a list.
|
||||||
type GameRow struct {
|
type GameRow struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -473,15 +498,30 @@ type BannerCampaignRow struct {
|
|||||||
|
|
||||||
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
// 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.
|
// "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 {
|
type BannerDetailView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Weight int
|
Weight int
|
||||||
IsDefault bool
|
IsDefault bool
|
||||||
Enabled bool
|
Enabled bool
|
||||||
StartsAt string
|
StartsAt string
|
||||||
EndsAt string
|
EndsAt string
|
||||||
Messages []BannerMessageRow
|
Urgent bool
|
||||||
|
OverrideAllOn bool
|
||||||
|
AllBg string
|
||||||
|
AllFg string
|
||||||
|
AllLink string
|
||||||
|
OverrideDarkOn bool
|
||||||
|
DarkBg string
|
||||||
|
DarkFg string
|
||||||
|
DarkLink string
|
||||||
|
Messages []BannerMessageRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
||||||
|
|||||||
+77
-13
@@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
|
|||||||
// window or message body).
|
// window or message body).
|
||||||
var ErrValidation = errors.New("ads: validation")
|
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.
|
// Campaign is one advertising placement order with its messages.
|
||||||
type Campaign struct {
|
type Campaign struct {
|
||||||
ID uuid.UUID // uuid.Nil on create
|
ID uuid.UUID // uuid.Nil on create
|
||||||
@@ -40,6 +49,16 @@ type Campaign struct {
|
|||||||
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
||||||
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
||||||
Messages []Message
|
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
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -70,10 +89,15 @@ type Timings struct {
|
|||||||
|
|
||||||
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
|
// 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
|
// its GCD-reduced show weight and its messages, already resolved to the viewer's
|
||||||
// language and in display (round-robin) order.
|
// 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.
|
||||||
type ActiveCampaign struct {
|
type ActiveCampaign struct {
|
||||||
Weight int
|
Weight int
|
||||||
Messages []string
|
Messages []string
|
||||||
|
OverrideAll *ColorSet
|
||||||
|
OverrideDark *ColorSet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eligible reports whether an account should be shown the advertising banner: a
|
// Eligible reports whether an account should be shown the advertising banner: a
|
||||||
@@ -85,14 +109,20 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
||||||
// at time now, in language lang. Campaigns outside their validity window, and
|
// at time now, in language lang, and reports whether the feed is an urgent one.
|
||||||
// campaigns with no messages, are dropped. The default campaign's effective
|
// Campaigns outside their validity window, and campaigns with no messages, are
|
||||||
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
|
// dropped.
|
||||||
// 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
|
// While any active campaign is urgent, the feed is the urgent campaigns alone —
|
||||||
// fair rotation cycle stays short. The input is expected to be the enabled
|
// every non-urgent timed campaign and the default remainder are suppressed for
|
||||||
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
|
// the duration (and the caller shows the feed to every viewer, bypassing
|
||||||
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
|
// 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) {
|
||||||
var timed []Campaign
|
var timed []Campaign
|
||||||
var def *Campaign
|
var def *Campaign
|
||||||
for i := range campaigns {
|
for i := range campaigns {
|
||||||
@@ -108,20 +138,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active
|
|||||||
timed = append(timed, c)
|
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
|
sumTimed := 0
|
||||||
for _, c := range timed {
|
for _, c := range timed {
|
||||||
sumTimed += c.Weight
|
sumTimed += c.Weight
|
||||||
}
|
}
|
||||||
out := make([]ActiveCampaign, 0, len(timed)+1)
|
out := make([]ActiveCampaign, 0, len(timed)+1)
|
||||||
for _, c := range timed {
|
for _, c := range timed {
|
||||||
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
|
out = append(out, activeFrom(c, lang))
|
||||||
}
|
}
|
||||||
if def != nil {
|
if def != nil {
|
||||||
if dw := 100 - sumTimed; dw > 0 {
|
if dw := 100 - sumTimed; dw > 0 {
|
||||||
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
|
a := activeFrom(*def, lang)
|
||||||
|
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
|
||||||
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reduceByGCD(out)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,19 @@ func TestComputeActiveSet(t *testing.T) {
|
|||||||
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
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}
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
campaigns []Campaign
|
campaigns []Campaign
|
||||||
lang string
|
lang string
|
||||||
want []ActiveCampaign
|
want []ActiveCampaign
|
||||||
|
wantUrgent bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default only reduces to weight 1",
|
name: "default only reduces to weight 1",
|
||||||
@@ -152,22 +159,71 @@ func TestComputeActiveSet(t *testing.T) {
|
|||||||
lang: "en",
|
lang: "en",
|
||||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := computeActiveSet(tt.campaigns, now, tt.lang)
|
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", 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) {
|
func TestComputeActiveSetEmpty(t *testing.T) {
|
||||||
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
||||||
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
|
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
|
||||||
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
|
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ads
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,17 +41,20 @@ func NewService(store *Store) *Service { return &Service{store: store} }
|
|||||||
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
||||||
// (en/ru) together with the global display timings: the currently-active
|
// (en/ru) together with the global display timings: the currently-active
|
||||||
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
||||||
// lang, ready for the client's weighted round-robin.
|
// lang, ready for the client's weighted round-robin. The bool result reports
|
||||||
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
|
// 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) {
|
||||||
campaigns, err := s.store.ActiveCampaigns(ctx)
|
campaigns, err := s.store.ActiveCampaigns(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, Timings{}, err
|
return nil, Timings{}, false, err
|
||||||
}
|
}
|
||||||
timings, err := s.store.Settings(ctx)
|
timings, err := s.store.Settings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, Timings{}, err
|
return nil, Timings{}, false, err
|
||||||
}
|
}
|
||||||
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
|
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
|
||||||
|
return set, timings, urgent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCampaigns returns every campaign with its messages, for the admin console.
|
// ListCampaigns returns every campaign with its messages, for the admin console.
|
||||||
@@ -76,8 +80,13 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
|
|||||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
return uuid.Nil, err
|
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{
|
return s.store.CreateCampaign(ctx, Campaign{
|
||||||
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
||||||
|
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +108,7 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
|||||||
upd.Enabled = true
|
upd.Enabled = true
|
||||||
upd.StartsAt = nil
|
upd.StartsAt = nil
|
||||||
upd.EndsAt = nil
|
upd.EndsAt = nil
|
||||||
|
// The default (house) campaign stays plain: no colour overrides, never urgent.
|
||||||
} else {
|
} else {
|
||||||
if err := validWeight(c.Weight); err != nil {
|
if err := validWeight(c.Weight); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -106,10 +116,17 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
|||||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
upd.Weight = c.Weight
|
upd.Weight = c.Weight
|
||||||
upd.Enabled = c.Enabled
|
upd.Enabled = c.Enabled
|
||||||
upd.StartsAt = c.StartsAt
|
upd.StartsAt = c.StartsAt
|
||||||
upd.EndsAt = c.EndsAt
|
upd.EndsAt = c.EndsAt
|
||||||
|
upd.OverrideAll = all
|
||||||
|
upd.OverrideDark = dark
|
||||||
|
upd.Urgent = c.Urgent
|
||||||
}
|
}
|
||||||
return s.store.UpdateCampaign(ctx, upd)
|
return s.store.UpdateCampaign(ctx, upd)
|
||||||
}
|
}
|
||||||
@@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error {
|
|||||||
return nil
|
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.
|
// validBodies trims and bounds both mandatory language bodies of a message.
|
||||||
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
||||||
en := strings.TrimSpace(bodyEn)
|
en := strings.TrimSpace(bodyEn)
|
||||||
|
|||||||
@@ -90,15 +90,23 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
|||||||
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
abg, afg, alink := colorCols(c.OverrideAll)
|
||||||
|
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
||||||
stmt := table.AdCampaigns.INSERT(
|
stmt := table.AdCampaigns.INSERT(
|
||||||
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
||||||
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
||||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
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,
|
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
||||||
).VALUES(
|
).VALUES(
|
||||||
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
||||||
postgres.Bool(false), postgres.Bool(c.Enabled),
|
postgres.Bool(false), postgres.Bool(c.Enabled),
|
||||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||||
|
abg, afg, alink,
|
||||||
|
dbg, dfg, dlink,
|
||||||
|
postgres.Bool(c.Urgent),
|
||||||
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||||
)
|
)
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
@@ -111,12 +119,20 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
|
|||||||
// window. The default flag is never touched here. Returns ErrNotFound when no
|
// window. The default flag is never touched here. Returns ErrNotFound when no
|
||||||
// campaign matches.
|
// campaign matches.
|
||||||
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
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(
|
stmt := table.AdCampaigns.UPDATE(
|
||||||
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
||||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
|
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,
|
||||||
).SET(
|
).SET(
|
||||||
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
||||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||||
|
abg, afg, alink,
|
||||||
|
dbg, dfg, dlink,
|
||||||
|
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
|
||||||
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
||||||
return execOne(ctx, s.db, stmt, "update campaign")
|
return execOne(ctx, s.db, stmt, "update campaign")
|
||||||
}
|
}
|
||||||
@@ -257,18 +273,40 @@ func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, wh
|
|||||||
|
|
||||||
func modelToCampaign(r model.AdCampaigns) Campaign {
|
func modelToCampaign(r model.AdCampaigns) Campaign {
|
||||||
return Campaign{
|
return Campaign{
|
||||||
ID: r.CampaignID,
|
ID: r.CampaignID,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Weight: int(r.Weight),
|
Weight: int(r.Weight),
|
||||||
IsDefault: r.IsDefault,
|
IsDefault: r.IsDefault,
|
||||||
Enabled: r.Enabled,
|
Enabled: r.Enabled,
|
||||||
StartsAt: r.StartsAt,
|
StartsAt: r.StartsAt,
|
||||||
EndsAt: r.EndsAt,
|
EndsAt: r.EndsAt,
|
||||||
CreatedAt: r.CreatedAt,
|
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
|
||||||
UpdatedAt: r.UpdatedAt,
|
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
|
||||||
|
Urgent: r.Urgent,
|
||||||
|
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 {
|
func modelToMessage(r model.AdMessages) Message {
|
||||||
return Message{
|
return Message{
|
||||||
ID: r.MessageID,
|
ID: r.MessageID,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -42,6 +43,12 @@ type Config struct {
|
|||||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||||
// selects the development log mailer (the code is logged, not sent).
|
// selects the development log mailer (the code is logged, not sent).
|
||||||
SMTP account.SMTPConfig
|
SMTP account.SMTPConfig
|
||||||
|
// PublicBaseURL is the canonical public origin (scheme + host, e.g.
|
||||||
|
// https://erudit-game.ru) used to build absolute links in outgoing email — the
|
||||||
|
// confirm deeplink and the footer landing link. It is deliberately not derived
|
||||||
|
// from a request Host header, which would let an attacker inject a phishing link
|
||||||
|
// into the email. Required whenever an SMTP relay is configured.
|
||||||
|
PublicBaseURL string
|
||||||
// ConnectorAddr is the gRPC address of the Telegram platform connector
|
// ConnectorAddr is the gRPC address of the Telegram platform connector
|
||||||
// side-service, used by the admin console to send operator broadcasts. Empty
|
// side-service, used by the admin console to send operator broadcasts. Empty
|
||||||
// disables broadcasts (the admin broadcast actions report "not configured").
|
// disables broadcasts (the admin broadcast actions report "not configured").
|
||||||
@@ -51,6 +58,12 @@ type Config struct {
|
|||||||
// GuestRetention is the account age past which an unused guest (no game seat)
|
// GuestRetention is the account age past which an unused guest (no game seat)
|
||||||
// is eligible for deletion by the reaper.
|
// is eligible for deletion by the reaper.
|
||||||
GuestRetention time.Duration
|
GuestRetention time.Duration
|
||||||
|
// ExportSignKey signs the finished-game export download URLs. Empty leaves
|
||||||
|
// the export-URL endpoints disabled (503 on mint, 404 on download).
|
||||||
|
ExportSignKey string
|
||||||
|
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||||
|
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||||
|
RendererURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults applied when the corresponding environment variable is unset.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
@@ -130,11 +143,14 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
smtp := account.SMTPConfig{
|
smtp := account.SMTPConfig{
|
||||||
Host: os.Getenv("BACKEND_SMTP_HOST"),
|
Host: os.Getenv("BACKEND_SMTP_HOST"),
|
||||||
Port: envOr("BACKEND_SMTP_PORT", "587"),
|
Port: envOr("BACKEND_SMTP_PORT", "587"),
|
||||||
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
|
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
|
||||||
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
||||||
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
||||||
|
TLS: os.Getenv("BACKEND_SMTP_TLS"),
|
||||||
|
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
|
||||||
|
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
c := Config{
|
c := Config{
|
||||||
@@ -148,9 +164,12 @@ func Load() (Config, error) {
|
|||||||
Robot: rb,
|
Robot: rb,
|
||||||
RateWatch: rw,
|
RateWatch: rw,
|
||||||
SMTP: smtp,
|
SMTP: smtp,
|
||||||
|
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
|
||||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||||
GuestReapInterval: guestReapInterval,
|
GuestReapInterval: guestReapInterval,
|
||||||
GuestRetention: guestRetention,
|
GuestRetention: guestRetention,
|
||||||
|
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||||
|
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -195,6 +214,14 @@ func (c Config) validate() error {
|
|||||||
if c.GuestRetention <= 0 {
|
if c.GuestRetention <= 0 {
|
||||||
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
|
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
|
||||||
}
|
}
|
||||||
|
if c.SMTP.Host != "" {
|
||||||
|
if c.PublicBaseURL == "" {
|
||||||
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured")
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,11 @@ func (svc *Service) CountUnread(ctx context.Context) (int, error) {
|
|||||||
return svc.store.CountUnread(ctx)
|
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.
|
// 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) {
|
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
||||||
return svc.store.Attachment(ctx, id)
|
return svc.store.Attachment(ctx, id)
|
||||||
|
|||||||
@@ -386,3 +386,15 @@ func (s *Store) CountUnread(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
return n, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1008,6 +1008,12 @@ func (svc *Service) CountComplaints(ctx context.Context, status string) (int, er
|
|||||||
return svc.store.CountComplaints(ctx, status)
|
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 /
|
// ResolveComplaint closes a complaint with an operator disposition (reject /
|
||||||
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
||||||
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
||||||
@@ -1363,30 +1369,53 @@ func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDr
|
|||||||
return svc.store.SetupDraws(ctx, gameID)
|
return svc.store.SetupDraws(ctx, gameID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
|
// ExportView returns a finished game with its journal and per-seat display names —
|
||||||
// is allowed only on a finished game: exporting an in-progress game would leak the
|
// the material every export artifact (the GCG text, the PNG render payload) is built
|
||||||
// full move journal mid-play, so an active game yields ErrGameActive.
|
// from. It is allowed only on a finished game: exporting an in-progress game would
|
||||||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
// leak the full move journal mid-play, so an active game yields ErrGameActive. In an
|
||||||
|
// honest-AI game the robot seat is labelled "AI", not its pool name.
|
||||||
|
func (svc *Service) ExportView(ctx context.Context, gameID uuid.UUID) (Game, []HistoryMove, []string, error) {
|
||||||
g, err := svc.store.GetGame(ctx, gameID)
|
g, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return Game{}, nil, nil, err
|
||||||
}
|
}
|
||||||
if g.Status != StatusFinished {
|
if g.Status != StatusFinished {
|
||||||
return "", ErrGameActive
|
return Game{}, nil, nil, ErrGameActive
|
||||||
}
|
}
|
||||||
moves, err := svc.store.GetJournal(ctx, gameID)
|
moves, err := svc.store.GetJournal(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return Game{}, nil, nil, err
|
||||||
}
|
}
|
||||||
names := svc.seatNames(ctx, g)
|
names := svc.seatNames(ctx, g)
|
||||||
if g.VsAI {
|
if g.VsAI {
|
||||||
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
|
|
||||||
for _, s := range g.Seats {
|
for _, s := range g.Seats {
|
||||||
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
|
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
|
||||||
names[s.Seat] = aiPlayerName
|
names[s.Seat] = aiPlayerName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return g, moves, names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureExportable reports whether a game may be exported (it exists and is
|
||||||
|
// finished) without loading the journal — the export-URL mint check.
|
||||||
|
func (svc *Service) EnsureExportable(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
g, err := svc.store.GetGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if g.Status != StatusFinished {
|
||||||
|
return ErrGameActive
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
|
||||||
|
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||||
|
g, moves, names, err := svc.ExportView(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return writeGCG(g, names, moves), nil
|
return writeGCG(g, names, moves), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1040,6 +1040,19 @@ func (s *Store) CountComplaints(ctx context.Context, status string) (int, error)
|
|||||||
return int(dest.Count), nil
|
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
|
// 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.
|
// filters them against the per-move deadline and the player's away window.
|
||||||
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,8 +192,9 @@ func TestBannerMessageOwnership(t *testing.T) {
|
|||||||
type profileBanner struct {
|
type profileBanner struct {
|
||||||
Banner *struct {
|
Banner *struct {
|
||||||
Campaigns []struct {
|
Campaigns []struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
Messages []string `json:"messages"`
|
||||||
|
OverrideBg string `json:"override_bg"`
|
||||||
} `json:"campaigns"`
|
} `json:"campaigns"`
|
||||||
Timings struct {
|
Timings struct {
|
||||||
HoldMs int `json:"hold_ms"`
|
HoldMs int `json:"hold_ms"`
|
||||||
@@ -274,3 +275,142 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
|||||||
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
// recover the confirm-code from the body.
|
// recover the confirm-code from the body.
|
||||||
type capturingMailer struct{ lastBody string }
|
type capturingMailer struct{ lastBody string }
|
||||||
|
|
||||||
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error {
|
func (m *capturingMailer) Send(_ context.Context, msg account.Message) error {
|
||||||
m.lastBody = body
|
m.lastBody = msg.Text
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ func TestEmailConfirmFlow(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(store, mailer)
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
email := "user-" + uuid.NewString() + "@example.com"
|
email := "user-" + uuid.NewString() + "@example.com"
|
||||||
@@ -67,7 +67,7 @@ func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(store, mailer)
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
owner := provisionAccount(t)
|
owner := provisionAccount(t)
|
||||||
email := "taken-" + uuid.NewString() + "@example.com"
|
email := "taken-" + uuid.NewString() + "@example.com"
|
||||||
@@ -89,7 +89,7 @@ func TestEmailCodeExpires(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(store, mailer)
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
email := "expire-" + uuid.NewString() + "@example.com"
|
email := "expire-" + uuid.NewString() + "@example.com"
|
||||||
@@ -111,7 +111,7 @@ func TestEmailTooManyAttempts(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(store, mailer)
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
email := "lock-" + uuid.NewString() + "@example.com"
|
email := "lock-" + uuid.NewString() + "@example.com"
|
||||||
@@ -203,10 +203,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
|||||||
func TestEmailLoginFlow(t *testing.T) {
|
func TestEmailLoginFlow(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
mailer := &capturingMailer{}
|
mailer := &capturingMailer{}
|
||||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
||||||
email := "login-" + uuid.NewString() + "@example.com"
|
email := "login-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00")
|
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login code: %v", err)
|
t.Fatalf("request login code: %v", err)
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A second login for the same email is the returning user: same account.
|
// A second login for the same email is the returning user: same account.
|
||||||
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil {
|
if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil {
|
||||||
t.Fatalf("second request: %v", err)
|
t.Fatalf("second request: %v", err)
|
||||||
}
|
}
|
||||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||||
@@ -244,3 +244,210 @@ func TestEmailLoginFlow(t *testing.T) {
|
|||||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEmailLoginProvisionsGuestUntilConfirmed covers the squat fix: an email-login
|
||||||
|
// account is a guest (reapable, so an abandoned never-confirmed login frees its
|
||||||
|
// address) until the code is confirmed, which promotes it to a durable account.
|
||||||
|
func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
email := "squat-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the
|
||||||
|
// code — no one-tap confirm link, which would otherwise open in a separate browser out of the
|
||||||
|
// PWA's reach. A non-PWA request keeps the link.
|
||||||
|
func TestEmailLoginPWAOmitsLink(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
|
pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com"
|
||||||
|
if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil {
|
||||||
|
t.Fatalf("pwa request login: %v", err)
|
||||||
|
}
|
||||||
|
if sixDigit.FindString(mailer.lastBody) == "" {
|
||||||
|
t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody)
|
||||||
|
}
|
||||||
|
if confirmToken.MatchString(mailer.lastBody) {
|
||||||
|
t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
webEmail := "web-login-" + uuid.NewString() + "@example.com"
|
||||||
|
if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil {
|
||||||
|
t.Fatalf("web request login: %v", err)
|
||||||
|
}
|
||||||
|
if !confirmToken.MatchString(mailer.lastBody) {
|
||||||
|
t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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", false)
|
||||||
|
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", false)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,7 @@ func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID
|
|||||||
|
|
||||||
func newLinkService(mailer account.Mailer) *link.Service {
|
func newLinkService(mailer account.Mailer) *link.Service {
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
emails := account.NewEmailService(store, mailer)
|
emails := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
|
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
|
||||||
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
|
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
|
||||||
}
|
}
|
||||||
@@ -251,6 +251,44 @@ 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.
|
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
|
||||||
func TestAccountLinkFreeEmail(t *testing.T) {
|
func TestAccountLinkFreeEmail(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -355,3 +393,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
|||||||
t.Errorf("email owner = %s, want durable", owner)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
|
|||||||
return s.accounts.ClearGuest(ctx, callerID)
|
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
|
// 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
|
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
||||||
// session when the active account switches.
|
// session when the active account switches.
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ func Notification(userID uuid.UUID, kind string) Intent {
|
|||||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
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
|
// 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
|
// 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.
|
// requests/friends lists and the in-game "add friend" state without a refetch.
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ const (
|
|||||||
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
|
// 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.
|
// banner set rides the profile response). In-app only.
|
||||||
NotifyBanner = "banner"
|
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,
|
// 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
|
// 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
|
// in-game block/add-friend controls and the struck name in place. It is delivered
|
||||||
|
|||||||
@@ -32,4 +32,8 @@ type Accounts struct {
|
|||||||
MergedAt *time.Time
|
MergedAt *time.Time
|
||||||
FlaggedHighRateAt *time.Time
|
FlaggedHighRateAt *time.Time
|
||||||
VariantPreferences pq.StringArray
|
VariantPreferences pq.StringArray
|
||||||
|
LastLoginAt *time.Time
|
||||||
|
LastLoginIP *string
|
||||||
|
DeletedAt *time.Time
|
||||||
|
DeletedDisplayName *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AdCampaigns struct {
|
type AdCampaigns struct {
|
||||||
CampaignID uuid.UUID `sql:"primary_key"`
|
CampaignID uuid.UUID `sql:"primary_key"`
|
||||||
Name string
|
Name string
|
||||||
Weight int32
|
Weight int32
|
||||||
IsDefault bool
|
IsDefault bool
|
||||||
Enabled bool
|
Enabled bool
|
||||||
StartsAt *time.Time
|
StartsAt *time.Time
|
||||||
EndsAt *time.Time
|
EndsAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
OverrideBg *string
|
||||||
|
OverrideFg *string
|
||||||
|
OverrideLink *string
|
||||||
|
OverrideBgDark *string
|
||||||
|
OverrideFgDark *string
|
||||||
|
OverrideLinkDark *string
|
||||||
|
Urgent bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ type EmailConfirmations struct {
|
|||||||
Attempts int16
|
Attempts int16
|
||||||
ConsumedAt *time.Time
|
ConsumedAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
Purpose string
|
||||||
|
LinkTokenHash *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//
|
||||||
|
// 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,6 +35,10 @@ type accountsTable struct {
|
|||||||
MergedAt postgres.ColumnTimestampz
|
MergedAt postgres.ColumnTimestampz
|
||||||
FlaggedHighRateAt postgres.ColumnTimestampz
|
FlaggedHighRateAt postgres.ColumnTimestampz
|
||||||
VariantPreferences postgres.ColumnStringArray
|
VariantPreferences postgres.ColumnStringArray
|
||||||
|
LastLoginAt postgres.ColumnTimestampz
|
||||||
|
LastLoginIP postgres.ColumnString
|
||||||
|
DeletedAt postgres.ColumnTimestampz
|
||||||
|
DeletedDisplayName postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -94,8 +98,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
|||||||
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
||||||
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
|
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
|
||||||
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
|
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
|
||||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
|
LastLoginAtColumn = postgres.TimestampzColumn("last_login_at")
|
||||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
|
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}
|
||||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
|
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,6 +129,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
|||||||
MergedAt: MergedAtColumn,
|
MergedAt: MergedAtColumn,
|
||||||
FlaggedHighRateAt: FlaggedHighRateAtColumn,
|
FlaggedHighRateAt: FlaggedHighRateAtColumn,
|
||||||
VariantPreferences: VariantPreferencesColumn,
|
VariantPreferences: VariantPreferencesColumn,
|
||||||
|
LastLoginAt: LastLoginAtColumn,
|
||||||
|
LastLoginIP: LastLoginIPColumn,
|
||||||
|
DeletedAt: DeletedAtColumn,
|
||||||
|
DeletedDisplayName: DeletedDisplayNameColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -17,15 +17,22 @@ type adCampaignsTable struct {
|
|||||||
postgres.Table
|
postgres.Table
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
CampaignID postgres.ColumnString
|
CampaignID postgres.ColumnString
|
||||||
Name postgres.ColumnString
|
Name postgres.ColumnString
|
||||||
Weight postgres.ColumnInteger
|
Weight postgres.ColumnInteger
|
||||||
IsDefault postgres.ColumnBool
|
IsDefault postgres.ColumnBool
|
||||||
Enabled postgres.ColumnBool
|
Enabled postgres.ColumnBool
|
||||||
StartsAt postgres.ColumnTimestampz
|
StartsAt postgres.ColumnTimestampz
|
||||||
EndsAt postgres.ColumnTimestampz
|
EndsAt postgres.ColumnTimestampz
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
UpdatedAt 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
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -67,33 +74,47 @@ func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable
|
|||||||
|
|
||||||
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
|
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
|
||||||
var (
|
var (
|
||||||
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
||||||
NameColumn = postgres.StringColumn("name")
|
NameColumn = postgres.StringColumn("name")
|
||||||
WeightColumn = postgres.IntegerColumn("weight")
|
WeightColumn = postgres.IntegerColumn("weight")
|
||||||
IsDefaultColumn = postgres.BoolColumn("is_default")
|
IsDefaultColumn = postgres.BoolColumn("is_default")
|
||||||
EnabledColumn = postgres.BoolColumn("enabled")
|
EnabledColumn = postgres.BoolColumn("enabled")
|
||||||
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
||||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
OverrideBgColumn = postgres.StringColumn("override_bg")
|
||||||
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
OverrideFgColumn = postgres.StringColumn("override_fg")
|
||||||
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn}
|
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}
|
||||||
)
|
)
|
||||||
|
|
||||||
return adCampaignsTable{
|
return adCampaignsTable{
|
||||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
CampaignID: CampaignIDColumn,
|
CampaignID: CampaignIDColumn,
|
||||||
Name: NameColumn,
|
Name: NameColumn,
|
||||||
Weight: WeightColumn,
|
Weight: WeightColumn,
|
||||||
IsDefault: IsDefaultColumn,
|
IsDefault: IsDefaultColumn,
|
||||||
Enabled: EnabledColumn,
|
Enabled: EnabledColumn,
|
||||||
StartsAt: StartsAtColumn,
|
StartsAt: StartsAtColumn,
|
||||||
EndsAt: EndsAtColumn,
|
EndsAt: EndsAtColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
UpdatedAt: UpdatedAtColumn,
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
OverrideBg: OverrideBgColumn,
|
||||||
|
OverrideFg: OverrideFgColumn,
|
||||||
|
OverrideLink: OverrideLinkColumn,
|
||||||
|
OverrideBgDark: OverrideBgDarkColumn,
|
||||||
|
OverrideFgDark: OverrideFgDarkColumn,
|
||||||
|
OverrideLinkDark: OverrideLinkDarkColumn,
|
||||||
|
Urgent: UrgentColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type emailConfirmationsTable struct {
|
|||||||
Attempts postgres.ColumnInteger
|
Attempts postgres.ColumnInteger
|
||||||
ConsumedAt postgres.ColumnTimestampz
|
ConsumedAt postgres.ColumnTimestampz
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
Purpose postgres.ColumnString
|
||||||
|
LinkTokenHash postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -74,9 +76,11 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
|
|||||||
AttemptsColumn = postgres.IntegerColumn("attempts")
|
AttemptsColumn = postgres.IntegerColumn("attempts")
|
||||||
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
|
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
PurposeColumn = postgres.StringColumn("purpose")
|
||||||
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
LinkTokenHashColumn = postgres.StringColumn("link_token_hash")
|
||||||
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
|
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}
|
||||||
)
|
)
|
||||||
|
|
||||||
return emailConfirmationsTable{
|
return emailConfirmationsTable{
|
||||||
@@ -91,6 +95,8 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
|
|||||||
Attempts: AttemptsColumn,
|
Attempts: AttemptsColumn,
|
||||||
ConsumedAt: ConsumedAtColumn,
|
ConsumedAt: ConsumedAtColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
|
Purpose: PurposeColumn,
|
||||||
|
LinkTokenHash: LinkTokenHashColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// 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,6 +34,7 @@ func UseSchema(schema string) {
|
|||||||
GameSetupDraws = GameSetupDraws.FromSchema(schema)
|
GameSetupDraws = GameSetupDraws.FromSchema(schema)
|
||||||
Games = Games.FromSchema(schema)
|
Games = Games.FromSchema(schema)
|
||||||
Identities = Identities.FromSchema(schema)
|
Identities = Identities.FromSchema(schema)
|
||||||
|
RetainedIdentities = RetainedIdentities.FromSchema(schema)
|
||||||
Sessions = Sessions.FromSchema(schema)
|
Sessions = Sessions.FromSchema(schema)
|
||||||
SuspensionReasons = SuspensionReasons.FromSchema(schema)
|
SuspensionReasons = SuspensionReasons.FromSchema(schema)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- 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])));
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Package render is the backend's client for the internal image-render sidecar: it POSTs
|
||||||
|
// a finished game's export payload and receives the rasterized PNG. The sidecar runs the
|
||||||
|
// same drawing code the web client unit-tests (ui/src/lib/gameimage.ts on skia-canvas);
|
||||||
|
// authentication and the signed public URL live in the server layer — this client only
|
||||||
|
// carries bytes.
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxPNG caps a render response; a scale-2 export of the largest plausible game is well
|
||||||
|
// under 2 MiB, so anything bigger is a sidecar malfunction, not a valid image.
|
||||||
|
const maxPNG = 8 << 20
|
||||||
|
|
||||||
|
// Client calls the render sidecar over the internal network.
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a client for the sidecar at baseURL (e.g. http://renderer:8090). A render
|
||||||
|
// of a full game takes well under a second; the timeout leaves room for a cold start.
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
return &Client{base: baseURL, http: &http.Client{Timeout: 15 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render posts payload (the JSON request shape the sidecar consumes) and returns the PNG.
|
||||||
|
func (c *Client) Render(ctx context.Context, payload any) ([]byte, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/render", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("render: sidecar status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
png, err := io.ReadAll(io.LimitReader(resp.Body, maxPNG+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: read response: %w", err)
|
||||||
|
}
|
||||||
|
if len(png) > maxPNG {
|
||||||
|
return nil, fmt.Errorf("render: response exceeds %d bytes", maxPNG)
|
||||||
|
}
|
||||||
|
return png, nil
|
||||||
|
}
|
||||||
@@ -22,10 +22,20 @@ type bannerDTO struct {
|
|||||||
|
|
||||||
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
// 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
|
// weight (for the client's smooth weighted round-robin) and its messages, in
|
||||||
// display order, already resolved to one language.
|
// 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.
|
||||||
type bannerCampaignDTO struct {
|
type bannerCampaignDTO struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
||||||
@@ -45,9 +55,34 @@ type bannerTimingsDTO struct {
|
|||||||
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
||||||
r := profileResponseFor(acc)
|
r := profileResponseFor(acc)
|
||||||
r.Banner = s.bannerFor(ctx, acc)
|
r.Banner = s.bannerFor(ctx, acc)
|
||||||
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
return r
|
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
|
// 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
|
// 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)
|
// see a banner. The message language follows the account's bot (service)
|
||||||
@@ -58,22 +93,27 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
|||||||
if s.ads == nil {
|
if s.ads == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
||||||
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 {
|
if err != nil {
|
||||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
return nil
|
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))
|
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
||||||
for _, c := range set {
|
for _, c := range set {
|
||||||
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
campaigns = append(campaigns, bannerCampaignFromActive(c))
|
||||||
}
|
}
|
||||||
return &bannerDTO{
|
return &bannerDTO{
|
||||||
Campaigns: campaigns,
|
Campaigns: campaigns,
|
||||||
@@ -88,6 +128,20 @@ 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
|
// bannerLang resolves the message language for a viewer: their interface language
|
||||||
// (Russian, or English by default).
|
// (Russian, or English by default).
|
||||||
func bannerLang(acc account.Account) string {
|
func bannerLang(acc account.Account) string {
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ type profileResponse struct {
|
|||||||
// see the banner (a free account with an empty hint wallet and without the
|
// see the banner (a free account with an empty hint wallet and without the
|
||||||
// no_banner role), absent otherwise. See banner.go.
|
// no_banner role), absent otherwise. See banner.go.
|
||||||
Banner *bannerDTO `json:"banner,omitempty"`
|
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.
|
// tileDTO is one placed (or to-place) tile.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func TestStatusForError(t *testing.T) {
|
|||||||
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
||||||
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
||||||
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
||||||
|
"too many codes": {account.ErrTooManyRequests, http.StatusTooManyRequests, "too_many_requests"},
|
||||||
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
|
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
|
||||||
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
||||||
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// Finished-game export downloads: a signed, short-lived, relative URL minted for a
|
||||||
|
// participant and later fetched with no credentials at all — the platforms' native
|
||||||
|
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain browser
|
||||||
|
// anchor) strip cookies and headers, so the HMAC in the URL is the whole grant.
|
||||||
|
// The client resolves the relative path against its own origin; the edge routes
|
||||||
|
// /dl/* to the gateway, which forwards it here (/api/v1/public/dl/...).
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportURLTTL is how long a minted download URL stays valid. Long enough for the
|
||||||
|
// platform's download dialog and a retry, short enough that a leaked link dies fast.
|
||||||
|
const exportURLTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// Query/label size caps: the presentation params ride the signed URL, so they are
|
||||||
|
// bounded defensively (they only ever hold a BCP-47 tag and four short UI words).
|
||||||
|
const (
|
||||||
|
maxDateLocaleLen = 35
|
||||||
|
maxLabelsLen = 120
|
||||||
|
maxTimeZoneLen = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportActionOrder fixes the position of each non-play move label in the `t` CSV.
|
||||||
|
var exportActionOrder = [4]string{"pass", "exchange", "resign", "timeout"}
|
||||||
|
|
||||||
|
// signExport computes the URL signature over the canonical parameter string. The
|
||||||
|
// canonical form is independent of the public path prefix, so the gateway may mount
|
||||||
|
// the route anywhere.
|
||||||
|
func (s *Server) signExport(gameID, kind, exp, dateLocale, timeZone, labels string) string {
|
||||||
|
mac := hmac.New(sha256.New, s.exportKey)
|
||||||
|
fmt.Fprintf(mac, "%s|%s|%s|%s|%s|%s", gameID, kind, exp, dateLocale, timeZone, labels)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportURLDTO is the minted link: a relative signed path plus the download filename.
|
||||||
|
type exportURLDTO struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExportURL mints the signed download path for a finished game's artifact.
|
||||||
|
// GET /api/v1/user/games/:id/export-url?kind=png|gcg&dl=<date-locale>&tz=<IANA zone>&t=<labels CSV>
|
||||||
|
func (s *Server) handleExportURL(c *gin.Context) {
|
||||||
|
if len(s.exportKey) == 0 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable,
|
||||||
|
errorResponse{Error: errorBody{Code: "export_unavailable", Message: "export signing is not configured"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, gameID, ok := s.userGame(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind := c.Query("kind")
|
||||||
|
if kind != "png" && kind != "gcg" {
|
||||||
|
abortBadRequest(c, "invalid kind")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dateLocale := c.Query("dl")
|
||||||
|
timeZone := c.Query("tz")
|
||||||
|
labels := c.Query("t")
|
||||||
|
if len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
|
||||||
|
abortBadRequest(c, "presentation params too long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.games.EnsureExportable(c.Request.Context(), gameID); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exp := strconv.FormatInt(time.Now().Add(exportURLTTL).Unix(), 10)
|
||||||
|
sig := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
|
||||||
|
q := url.Values{"e": {exp}, "s": {sig}}
|
||||||
|
if dateLocale != "" {
|
||||||
|
q.Set("l", dateLocale)
|
||||||
|
}
|
||||||
|
if timeZone != "" {
|
||||||
|
q.Set("z", timeZone)
|
||||||
|
}
|
||||||
|
if labels != "" {
|
||||||
|
q.Set("t", labels)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, exportURLDTO{
|
||||||
|
Path: "/dl/" + gameID.String() + "/" + kind + "?" + q.Encode(),
|
||||||
|
Filename: exportFilename(gameID, kind),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportFilename(gameID uuid.UUID, kind string) string {
|
||||||
|
return "game-" + gameID.String()[:8] + "." + kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExportDownload serves a minted artifact. It is a public route: the signature
|
||||||
|
// and expiry are the only authorization (see the package comment). Every failure is
|
||||||
|
// a plain 404 so the endpoint is not a validity oracle.
|
||||||
|
// GET /api/v1/public/dl/:id/:kind?e=&l=&z=&t=&s=
|
||||||
|
func (s *Server) handleExportDownload(c *gin.Context) {
|
||||||
|
if len(s.exportKey) == 0 {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, err := uuid.Parse(c.Param("id"))
|
||||||
|
kind := c.Param("kind")
|
||||||
|
exp := c.Query("e")
|
||||||
|
dateLocale := c.Query("l")
|
||||||
|
timeZone := c.Query("z")
|
||||||
|
labels := c.Query("t")
|
||||||
|
sig := c.Query("s")
|
||||||
|
if err != nil || (kind != "png" && kind != "gcg") ||
|
||||||
|
len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
|
||||||
|
if subtleNeq(sig, want) {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if unix, err := strconv.ParseInt(exp, 10, 64); err != nil || time.Now().Unix() > unix {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := exportFilename(gameID, kind)
|
||||||
|
if kind == "gcg" {
|
||||||
|
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveAttachment(c, filename, "text/plain; charset=utf-8", []byte(gcg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.renderer == nil {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g, moves, names, err := s.games.ExportView(c.Request.Context(), gameID)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := renderPayload(g, moves, names, dateLocale, timeZone, labels, c.GetHeader("X-Public-Host"))
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("export render payload", zap.Error(err))
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
png, err := s.renderer.Render(c.Request.Context(), payload)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("export render", zap.Error(err))
|
||||||
|
c.String(http.StatusServiceUnavailable, "render unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveAttachment(c, filename, "image/png", png)
|
||||||
|
}
|
||||||
|
|
||||||
|
// subtleNeq compares two signature strings in constant time.
|
||||||
|
func subtleNeq(got, want string) bool {
|
||||||
|
return !hmac.Equal([]byte(got), []byte(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveAttachment writes bytes as a named file download. Content-Length is set
|
||||||
|
// explicitly: a body over net/http's write buffer otherwise goes out chunked, and
|
||||||
|
// Android's system DownloadManager (which VKWebAppDownloadFile delegates to) hangs
|
||||||
|
// forever on a download of unknown length.
|
||||||
|
func serveAttachment(c *gin.Context, filename, contentType string, data []byte) {
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(filename)+`"`)
|
||||||
|
c.Header("Cache-Control", "private, max-age=0")
|
||||||
|
c.Header("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
c.Data(http.StatusOK, contentType, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the render-sidecar request payload (the ui-model shapes drawGameImage consumes) ---
|
||||||
|
|
||||||
|
type renderSeatJSON struct {
|
||||||
|
Seat int `json:"seat"`
|
||||||
|
AccountID string `json:"accountId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
HintsUsed int `json:"hintsUsed"`
|
||||||
|
IsWinner bool `json:"isWinner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderGameJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Players int `json:"players"`
|
||||||
|
LastActivityUnix int64 `json:"lastActivityUnix"`
|
||||||
|
Seats []renderSeatJSON `json:"seats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderTileJSON struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Blank bool `json:"blank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderMoveJSON struct {
|
||||||
|
Player int `json:"player"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
MainRow int `json:"mainRow"`
|
||||||
|
MainCol int `json:"mainCol"`
|
||||||
|
Tiles []renderTileJSON `json:"tiles"`
|
||||||
|
Words []string `json:"words"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderAlphaJSON struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderRequestJSON struct {
|
||||||
|
Game renderGameJSON `json:"game"`
|
||||||
|
Moves []renderMoveJSON `json:"moves"`
|
||||||
|
Alphabet []renderAlphaJSON `json:"alphabet"`
|
||||||
|
Labels map[string]string `json:"labels"`
|
||||||
|
DateLocale string `json:"dateLocale"`
|
||||||
|
TimeZone string `json:"timeZone"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPayload assembles the sidecar request from the export view. Letters are
|
||||||
|
// upper-cased for display exactly as the client codec does; the localized non-play
|
||||||
|
// labels arrive as a positional CSV (see exportActionOrder) minted into the URL.
|
||||||
|
func renderPayload(g game.Game, moves []game.HistoryMove, names []string, dateLocale, timeZone, labelsCSV, host string) (renderRequestJSON, error) {
|
||||||
|
alpha, err := engine.AlphabetTable(g.Variant)
|
||||||
|
if err != nil {
|
||||||
|
return renderRequestJSON{}, fmt.Errorf("alphabet for %s: %w", g.Variant, err)
|
||||||
|
}
|
||||||
|
req := renderRequestJSON{
|
||||||
|
Game: renderGameJSON{
|
||||||
|
ID: g.ID.String(),
|
||||||
|
Variant: g.Variant.String(),
|
||||||
|
Status: g.Status,
|
||||||
|
Players: g.Players,
|
||||||
|
},
|
||||||
|
Labels: map[string]string{},
|
||||||
|
DateLocale: dateLocale,
|
||||||
|
TimeZone: timeZone,
|
||||||
|
Hostname: host,
|
||||||
|
}
|
||||||
|
finished := g.UpdatedAt
|
||||||
|
if g.FinishedAt != nil {
|
||||||
|
finished = *g.FinishedAt
|
||||||
|
}
|
||||||
|
req.Game.LastActivityUnix = finished.Unix()
|
||||||
|
for _, st := range g.Seats {
|
||||||
|
name := st.DisplayName
|
||||||
|
if st.Seat < len(names) {
|
||||||
|
name = names[st.Seat]
|
||||||
|
}
|
||||||
|
req.Game.Seats = append(req.Game.Seats, renderSeatJSON{
|
||||||
|
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: name,
|
||||||
|
Score: st.Score, HintsUsed: st.HintsUsed, IsWinner: st.IsWinner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, m := range moves {
|
||||||
|
mv := renderMoveJSON{
|
||||||
|
Player: m.Seat, Action: m.Action, Dir: m.Dir,
|
||||||
|
MainRow: m.MainRow, MainCol: m.MainCol,
|
||||||
|
Words: make([]string, 0, len(m.Words)),
|
||||||
|
Count: len(m.Exchanged), Score: m.Score, Total: m.RunningTotal,
|
||||||
|
Tiles: make([]renderTileJSON, 0, len(m.Tiles)),
|
||||||
|
}
|
||||||
|
for _, w := range m.Words {
|
||||||
|
mv.Words = append(mv.Words, strings.ToUpper(w))
|
||||||
|
}
|
||||||
|
for _, t := range m.Tiles {
|
||||||
|
mv.Tiles = append(mv.Tiles, renderTileJSON{Row: t.Row, Col: t.Col, Letter: strings.ToUpper(t.Letter), Blank: t.Blank})
|
||||||
|
}
|
||||||
|
req.Moves = append(req.Moves, mv)
|
||||||
|
}
|
||||||
|
for _, a := range alpha {
|
||||||
|
req.Alphabet = append(req.Alphabet, renderAlphaJSON{Index: int(a.Index), Letter: strings.ToUpper(a.Letter), Value: a.Value})
|
||||||
|
}
|
||||||
|
if labelsCSV != "" {
|
||||||
|
parts := strings.Split(labelsCSV, ",")
|
||||||
|
for i, action := range exportActionOrder {
|
||||||
|
if i < len(parts) && parts[i] != "" {
|
||||||
|
req.Labels[action] = parts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newExportServer is the routing harness with a signing key installed, so the
|
||||||
|
// pre-service branches of the export endpoints are reachable.
|
||||||
|
func newExportServer() *Server {
|
||||||
|
return New(":0", Deps{
|
||||||
|
Sessions: &session.Service{},
|
||||||
|
Accounts: &account.Store{},
|
||||||
|
Games: &game.Service{},
|
||||||
|
ExportSignKey: "test-key",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignExportIsDeterministicAndTamperEvident(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
sig := s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен")
|
||||||
|
if sig != s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен") {
|
||||||
|
t.Fatal("same inputs produced different signatures")
|
||||||
|
}
|
||||||
|
for _, tampered := range []string{
|
||||||
|
s.signExport("g2", "png", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "gcg", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "124", "ru", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "en", "Europe/Moscow", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "ru", "UTC", "пас,обмен"),
|
||||||
|
s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас"),
|
||||||
|
} {
|
||||||
|
if tampered == sig {
|
||||||
|
t.Fatal("tampered input produced the same signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportURLWithoutKeyIs503(t *testing.T) {
|
||||||
|
s := New(":0", Deps{Sessions: &session.Service{}, Accounts: &account.Store{}, Games: &game.Service{}})
|
||||||
|
rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+uuid.NewString()+"/export-url?kind=png", "",
|
||||||
|
map[string]string{"X-User-ID": uuid.NewString()})
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("mint without key = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportURLRejectsBadParams(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
uid := map[string]string{"X-User-ID": uuid.NewString()}
|
||||||
|
gid := uuid.NewString()
|
||||||
|
|
||||||
|
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=exe", "", uid); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad kind = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
long := strings.Repeat("x", maxLabelsLen+1)
|
||||||
|
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=png&t="+long, "", uid); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("oversized labels = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportDownloadRejectsBadSignatures(t *testing.T) {
|
||||||
|
s := newExportServer()
|
||||||
|
gid := uuid.NewString()
|
||||||
|
exp := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)
|
||||||
|
|
||||||
|
// A wrong signature never reaches the domain (404 before any service call).
|
||||||
|
url := "/api/v1/public/dl/" + gid + "/png?e=" + exp + "&s=forged"
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("forged signature = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid signature over an EXPIRED timestamp is refused the same way.
|
||||||
|
old := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)
|
||||||
|
url = "/api/v1/public/dl/" + gid + "/png?e=" + old + "&s=" + s.signExport(gid, "png", old, "", "", "")
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expired link = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown kind is refused before signature checks.
|
||||||
|
url = "/api/v1/public/dl/" + gid + "/exe?e=" + exp + "&s=x"
|
||||||
|
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("bad kind = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPayloadMapsTheExportView(t *testing.T) {
|
||||||
|
gid := uuid.New()
|
||||||
|
finished := time.Unix(1_782_997_629, 0)
|
||||||
|
g := game.Game{
|
||||||
|
ID: gid,
|
||||||
|
Variant: engine.VariantRussianScrabble,
|
||||||
|
Status: game.StatusFinished,
|
||||||
|
Players: 2,
|
||||||
|
FinishedAt: &finished,
|
||||||
|
Seats: []game.Seat{
|
||||||
|
{Seat: 0, Score: 42, IsWinner: true, DisplayName: "snapshot"},
|
||||||
|
{Seat: 1, Score: 30},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
moves := []game.HistoryMove{
|
||||||
|
{
|
||||||
|
Seat: 0, Action: "play", Dir: "H", MainRow: 7, MainCol: 4,
|
||||||
|
Tiles: []engine.TileRecord{{Row: 7, Col: 4, Letter: "ч", Blank: false}},
|
||||||
|
Words: []string{"чика"}, Score: 9, RunningTotal: 9,
|
||||||
|
},
|
||||||
|
{Seat: 1, Action: "exchange", Exchanged: []string{"а", "б"}, RunningTotal: 0},
|
||||||
|
}
|
||||||
|
names := []string{"Аня", "Боб"}
|
||||||
|
|
||||||
|
p, err := renderPayload(g, moves, names, "ru", "Europe/Moscow", "пас,обмен,сдался,таймаут", "erudit-game.ru")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderPayload: %v", err)
|
||||||
|
}
|
||||||
|
if p.Game.LastActivityUnix != finished.Unix() {
|
||||||
|
t.Fatalf("lastActivityUnix = %d, want %d", p.Game.LastActivityUnix, finished.Unix())
|
||||||
|
}
|
||||||
|
if p.Game.Seats[0].DisplayName != "Аня" || !p.Game.Seats[0].IsWinner {
|
||||||
|
t.Fatalf("seat 0 = %+v, want the resolved name and the winner flag", p.Game.Seats[0])
|
||||||
|
}
|
||||||
|
if p.Moves[0].Words[0] != "ЧИКА" || p.Moves[0].Tiles[0].Letter != "Ч" {
|
||||||
|
t.Fatalf("letters not upper-cased: %+v", p.Moves[0])
|
||||||
|
}
|
||||||
|
if p.Moves[1].Count != 2 {
|
||||||
|
t.Fatalf("exchange count = %d, want 2", p.Moves[1].Count)
|
||||||
|
}
|
||||||
|
if p.Labels["pass"] != "пас" || p.Labels["timeout"] != "таймаут" {
|
||||||
|
t.Fatalf("labels not mapped positionally: %v", p.Labels)
|
||||||
|
}
|
||||||
|
if len(p.Alphabet) == 0 || p.Hostname != "erudit-game.ru" || p.TimeZone != "Europe/Moscow" {
|
||||||
|
t.Fatalf("alphabet/hostname/zone missing: %d entries, host %q, tz %q", len(p.Alphabet), p.Hostname, p.TimeZone)
|
||||||
|
}
|
||||||
|
for _, a := range p.Alphabet {
|
||||||
|
if a.Letter != strings.ToUpper(a.Letter) {
|
||||||
|
t.Fatalf("alphabet letter %q not upper-cased", a.Letter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ func (s *Server) registerRoutes() {
|
|||||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||||
|
in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
|
||||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||||
// Out-of-app push routing for the platform side-service: the
|
// Out-of-app push routing for the platform side-service: the
|
||||||
@@ -73,6 +74,18 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
||||||
u.POST("/link/telegram", s.handleLinkTelegram)
|
u.POST("/link/telegram", s.handleLinkTelegram)
|
||||||
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
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 {
|
if s.games != nil {
|
||||||
u.GET("/games", s.handleListGames)
|
u.GET("/games", s.handleListGames)
|
||||||
@@ -87,12 +100,17 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||||
u.GET("/games/:id/history", s.handleHistory)
|
u.GET("/games/:id/history", s.handleHistory)
|
||||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||||
|
u.GET("/games/:id/export-url", s.handleExportURL)
|
||||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||||
u.POST("/games/:id/hide", s.handleHideGame)
|
u.POST("/games/:id/hide", s.handleHideGame)
|
||||||
// Raw dictionary download for the client-side local move preview, keyed by
|
// Raw dictionary download for the client-side local move preview, keyed by
|
||||||
// the game's pinned (variant, version); immutable, so cached hard.
|
// the game's pinned (variant, version); immutable, so cached hard.
|
||||||
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
||||||
|
// The signed finished-game export download — the only public data route:
|
||||||
|
// the URL's HMAC + expiry are the whole grant (export.go), because the
|
||||||
|
// platforms' native download calls carry no credentials.
|
||||||
|
s.public.GET("/dl/:id/:kind", s.handleExportDownload)
|
||||||
}
|
}
|
||||||
if s.feedback != nil {
|
if s.feedback != nil {
|
||||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||||
@@ -225,6 +243,8 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusUnprocessableEntity, "illegal_play"
|
return http.StatusUnprocessableEntity, "illegal_play"
|
||||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||||
return http.StatusConflict, "email_taken"
|
return http.StatusConflict, "email_taken"
|
||||||
|
case errors.Is(err, account.ErrLastIdentity):
|
||||||
|
return http.StatusConflict, "last_identity"
|
||||||
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
||||||
return http.StatusConflict, "merge_active_game_conflict"
|
return http.StatusConflict, "merge_active_game_conflict"
|
||||||
case errors.Is(err, account.ErrInvalidEmail):
|
case errors.Is(err, account.ErrInvalidEmail):
|
||||||
@@ -232,6 +252,8 @@ func statusForError(err error) (int, string) {
|
|||||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||||
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
|
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
|
||||||
return http.StatusUnauthorized, "code_invalid"
|
return http.StatusUnauthorized, "code_invalid"
|
||||||
|
case errors.Is(err, account.ErrTooManyRequests):
|
||||||
|
return http.StatusTooManyRequests, "too_many_requests"
|
||||||
case errors.Is(err, session.ErrNotFound):
|
case errors.Is(err, session.ErrNotFound):
|
||||||
return http.StatusUnauthorized, "session_invalid"
|
return http.StatusUnauthorized, "session_invalid"
|
||||||
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ const bannersBack = "/_gm/banners"
|
|||||||
// bound; the operator's entry is interpreted as UTC.
|
// bound; the operator's entry is interpreted as UTC.
|
||||||
const localTimeLayout = "2006-01-02T15:04"
|
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
|
// consoleBanners lists the advertising campaigns (default first), each with its
|
||||||
// weight, validity window, enabled flag, message count and live-now status.
|
// weight, validity window, enabled flag, message count and live-now status.
|
||||||
func (s *Server) consoleBanners(c *gin.Context) {
|
func (s *Server) consoleBanners(c *gin.Context) {
|
||||||
@@ -56,14 +65,19 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
view := adminconsole.BannerDetailView{
|
view := adminconsole.BannerDetailView{
|
||||||
ID: cm.ID.String(),
|
ID: cm.ID.String(),
|
||||||
Name: cm.Name,
|
Name: cm.Name,
|
||||||
Weight: cm.Weight,
|
Weight: cm.Weight,
|
||||||
IsDefault: cm.IsDefault,
|
IsDefault: cm.IsDefault,
|
||||||
Enabled: cm.Enabled,
|
Enabled: cm.Enabled,
|
||||||
StartsAt: localInput(cm.StartsAt),
|
StartsAt: localInput(cm.StartsAt),
|
||||||
EndsAt: localInput(cm.EndsAt),
|
EndsAt: localInput(cm.EndsAt),
|
||||||
|
Urgent: cm.Urgent,
|
||||||
|
OverrideAllOn: cm.OverrideAll != nil,
|
||||||
|
OverrideDarkOn: cm.OverrideDark != nil,
|
||||||
}
|
}
|
||||||
|
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 {
|
for i, m := range cm.Messages {
|
||||||
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
||||||
ID: m.ID.String(),
|
ID: m.ID.String(),
|
||||||
@@ -111,12 +125,15 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
|
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: trimForm(c, "name"),
|
Name: trimForm(c, "name"),
|
||||||
Weight: atoiForm(c, "weight"),
|
Weight: atoiForm(c, "weight"),
|
||||||
Enabled: c.PostForm("enabled") != "",
|
Enabled: c.PostForm("enabled") != "",
|
||||||
StartsAt: starts,
|
StartsAt: starts,
|
||||||
EndsAt: ends,
|
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"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.bannerError(c, err, back)
|
s.bannerError(c, err, back)
|
||||||
@@ -321,3 +338,29 @@ func atoiForm(c *gin.Context, name string) int {
|
|||||||
n, _ := strconv.Atoi(trimForm(c, name))
|
n, _ := strconv.Atoi(trimForm(c, name))
|
||||||
return n
|
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,6 +61,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
||||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
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.GET("/reasons", s.consoleReasons)
|
||||||
gm.POST("/reasons", s.consoleCreateReason)
|
gm.POST("/reasons", s.consoleCreateReason)
|
||||||
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
||||||
@@ -132,8 +134,10 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
page := consolePage(c)
|
page := consolePage(c)
|
||||||
filter := account.UserFilter{
|
filter := account.UserFilter{
|
||||||
Robots: c.Query("kind") == "robots",
|
Robots: c.Query("kind") == "robots",
|
||||||
|
Deleted: c.Query("kind") == "deleted",
|
||||||
NameMask: c.Query("name"),
|
NameMask: c.Query("name"),
|
||||||
ExternalIDMask: c.Query("ext"),
|
ExternalIDMask: c.Query("ext"),
|
||||||
|
EmailExact: c.Query("email"),
|
||||||
}
|
}
|
||||||
total, _ := s.accounts.CountUsers(ctx, filter)
|
total, _ := s.accounts.CountUsers(ctx, filter)
|
||||||
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
||||||
@@ -145,28 +149,38 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
if filter.Robots {
|
if filter.Robots {
|
||||||
q.Set("kind", "robots")
|
q.Set("kind", "robots")
|
||||||
}
|
}
|
||||||
|
if filter.Deleted {
|
||||||
|
q.Set("kind", "deleted")
|
||||||
|
}
|
||||||
if strings.TrimSpace(filter.NameMask) != "" {
|
if strings.TrimSpace(filter.NameMask) != "" {
|
||||||
q.Set("name", filter.NameMask)
|
q.Set("name", filter.NameMask)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(filter.ExternalIDMask) != "" {
|
if strings.TrimSpace(filter.ExternalIDMask) != "" {
|
||||||
q.Set("ext", filter.ExternalIDMask)
|
q.Set("ext", filter.ExternalIDMask)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(filter.EmailExact) != "" {
|
||||||
|
q.Set("email", filter.EmailExact)
|
||||||
|
}
|
||||||
view := adminconsole.UsersView{
|
view := adminconsole.UsersView{
|
||||||
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
Robots: filter.Robots, Deleted: filter.Deleted, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
||||||
|
EmailExact: filter.EmailExact,
|
||||||
FilterQuery: template.URL(q.Encode()),
|
FilterQuery: template.URL(q.Encode()),
|
||||||
}
|
}
|
||||||
ids := make([]uuid.UUID, 0, len(items))
|
ids := make([]uuid.UUID, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
kind := "registered"
|
kind := "registered"
|
||||||
if it.IsRobot {
|
switch {
|
||||||
|
case it.IsRobot:
|
||||||
kind = "robot"
|
kind = "robot"
|
||||||
} else if it.IsGuest {
|
case it.IsDeleted:
|
||||||
|
kind = "deleted"
|
||||||
|
case it.IsGuest:
|
||||||
kind = "guest"
|
kind = "guest"
|
||||||
}
|
}
|
||||||
view.Items = append(view.Items, adminconsole.UserRow{
|
view.Items = append(view.Items, adminconsole.UserRow{
|
||||||
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
||||||
Language: it.PreferredLanguage, Guest: it.IsGuest,
|
Language: it.PreferredLanguage, Guest: it.IsGuest, Deleted: it.IsDeleted,
|
||||||
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
|
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
|
||||||
})
|
})
|
||||||
ids = append(ids, it.ID)
|
ids = append(ids, it.ID)
|
||||||
@@ -356,9 +370,31 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if ids, err := s.accounts.Identities(ctx, id); err == nil {
|
if ids, err := s.accounts.Identities(ctx, id); err == nil {
|
||||||
for _, idn := range ids {
|
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)})
|
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 {
|
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
||||||
view.TelegramID = tg
|
view.TelegramID = tg
|
||||||
}
|
}
|
||||||
@@ -951,6 +987,41 @@ 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)
|
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
|
// 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
|
// 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.
|
// games, removing them from matchmaking. The block takes effect on the player's next request.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
||||||
@@ -172,6 +173,11 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
|
|||||||
type emailRequest struct {
|
type emailRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
// Pwa marks a request from an installed PWA (standalone display mode): the login email then
|
||||||
|
// omits the one-tap confirm link so the code is typed in the same window (the link would open
|
||||||
|
// in a separate browser whose session cannot reach the PWA). See EmailService.RequestLoginCode.
|
||||||
|
Pwa bool `json:"pwa"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
||||||
@@ -183,7 +189,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
|
|||||||
abortBadRequest(c, "email is required")
|
abortBadRequest(c, "email is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil {
|
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language, req.Pwa); err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -211,6 +217,56 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
|
|||||||
s.mintSession(c, acc)
|
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.
|
// tokenRequest carries an opaque session token.
|
||||||
type tokenRequest struct {
|
type tokenRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ type linkTelegramBody struct {
|
|||||||
ExternalID string `json:"external_id"`
|
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
|
// 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
|
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
||||||
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
||||||
@@ -66,6 +73,89 @@ func (s *Server) handleLinkEmailRequest(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
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
|
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
|
||||||
// required merge.
|
// required merge.
|
||||||
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
|
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
|
||||||
@@ -149,6 +239,48 @@ func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
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)
|
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
||||||
// or a completed link (the active account's refreshed profile).
|
// or a completed link (the active account's refreshed profile).
|
||||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ func (s *Server) handleProfile(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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))
|
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
@@ -96,6 +97,12 @@ type Deps struct {
|
|||||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||||
// them (notify.Nop).
|
// them (notify.Nop).
|
||||||
Notifier notify.Publisher
|
Notifier notify.Publisher
|
||||||
|
// ExportSignKey signs the finished-game export download URLs (export.go). An
|
||||||
|
// empty key leaves the export-URL endpoints answering 503/404.
|
||||||
|
ExportSignKey string
|
||||||
|
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||||
|
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||||
|
Renderer *render.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -124,6 +131,8 @@ type Server struct {
|
|||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
|
exportKey []byte
|
||||||
|
renderer *render.Client
|
||||||
|
|
||||||
public *gin.RouterGroup
|
public *gin.RouterGroup
|
||||||
user *gin.RouterGroup
|
user *gin.RouterGroup
|
||||||
@@ -173,8 +182,12 @@ func New(addr string, deps Deps) *Server {
|
|||||||
banview: deps.BanView,
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
|
renderer: deps.Renderer,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
}
|
}
|
||||||
|
if deps.ExportSignKey != "" {
|
||||||
|
s.exportKey = []byte(deps.ExportSignKey)
|
||||||
|
}
|
||||||
s.registerProbes(engine)
|
s.registerProbes(engine)
|
||||||
s.registerAPIGroups(engine)
|
s.registerAPIGroups(engine)
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
|
|||||||
+59
-5
@@ -1,6 +1,10 @@
|
|||||||
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
# 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
|
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
||||||
# deploy maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
|
# 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).
|
||||||
#
|
#
|
||||||
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
||||||
|
|
||||||
@@ -15,12 +19,48 @@ POSTGRES_PASSWORD=change-me # required
|
|||||||
# boot the dawg-data volume preserves versions uploaded through the admin console and
|
# 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
|
# 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
|
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
||||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea
|
||||||
DICT_VERSION=v1.3.0
|
# variable (DICT_VERSION) seeds both contours + pins the CI test suite.
|
||||||
|
DICT_VERSION=v1.3.1
|
||||||
|
|
||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# --- Finished-game export ----------------------------------------------------
|
||||||
|
# HMAC key signing the public export download URLs (/dl/*). Required; generate a
|
||||||
|
# real contour value with `openssl rand -base64 32` (Gitea TEST_/PROD_EXPORT_SIGN_KEY).
|
||||||
|
EXPORT_SIGN_KEY=dev-export-sign-key
|
||||||
|
|
||||||
|
# --- Transactional email (Selectel relay) -----------------------------------
|
||||||
|
# Confirm-code email via the shared Selectel relay (one relay for every contour;
|
||||||
|
# limit 100 msgs / 5 min). Empty SMTP_RELAY_HOST makes the backend log codes instead
|
||||||
|
# of sending (dev). Port 465 = implicit TLS, else STARTTLS; no client cert is needed.
|
||||||
|
# TLS is implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it
|
||||||
|
# (ssl|starttls) — required for Selectel's non-standard ports (1127 = SSL, 1126 =
|
||||||
|
# STARTTLS). FROM must use the prod domain (Selectel only accepts the verified sender
|
||||||
|
# domain), so it is the same on every contour; a display name is fine
|
||||||
|
# (`"Игра Эрудит" <no-reply@erudit-game.ru>`). PUBLIC_BASE_URL is the canonical https
|
||||||
|
# origin for links in the email — the contour's own public URL. Gitea
|
||||||
|
# TEST_/PROD_SMTP_RELAY_* + TEST_/PROD_PUBLIC_BASE_URL.
|
||||||
|
SMTP_RELAY_HOST=
|
||||||
|
SMTP_RELAY_PORT=465
|
||||||
|
SMTP_RELAY_TLS= # empty = auto by port; ssl | starttls to force
|
||||||
|
SMTP_RELAY_USER= # secret
|
||||||
|
SMTP_RELAY_PASS= # secret
|
||||||
|
SMTP_RELAY_FROM=no-reply@erudit-game.ru
|
||||||
|
PUBLIC_BASE_URL= # required when SMTP_RELAY_HOST is set (e.g. https://erudit-game.ru)
|
||||||
|
|
||||||
|
# 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 -----------------------------------------------------------
|
# --- Edge / caddy -----------------------------------------------------------
|
||||||
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
||||||
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
||||||
@@ -32,10 +72,13 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
|||||||
VITE_TELEGRAM_BOT_ID=
|
VITE_TELEGRAM_BOT_ID=
|
||||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||||
|
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||||
|
VITE_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=
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
# --- Grafana ----------------------------------------------------------------
|
# --- Grafana ----------------------------------------------------------------
|
||||||
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
|
GRAFANA_ROOT_URL=/_gm/grafana/ # deploy derives PUBLIC_BASE_URL + /_gm/grafana/; set the full https URL for a local run
|
||||||
GRAFANA_ADMIN_PASSWORD=admin
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
# --- Telegram validator + bot -----------------------------------------------
|
# --- Telegram validator + bot -----------------------------------------------
|
||||||
@@ -52,7 +95,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_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_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_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
|
TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|
||||||
@@ -61,3 +104,14 @@ TELEGRAM_API_BASE_URL=
|
|||||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
# 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.
|
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||||
GATEWAY_VK_APP_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=
|
||||||
|
|||||||
+78
-20
@@ -16,6 +16,7 @@ operational reference for **every environment variable**.
|
|||||||
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. |
|
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. |
|
||||||
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
|
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
|
||||||
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
|
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
|
||||||
|
| `renderer` | built (`renderer/Dockerfile`) | Finished-game image-render sidecar (Node + skia-canvas running the shared `ui/src/lib/gameimage.ts`); internal-only HTTP at `renderer:8090`, called by the backend for the PNG export artifact. |
|
||||||
| `validator` | built (`platform/telegram/Dockerfile`, target `validator`) | Telegram HMAC validator (no VPN, no Bot API); internal gRPC at `validator:9091`. Game login depends only on this. |
|
| `validator` | built (`platform/telegram/Dockerfile`, target `validator`) | Telegram HMAC validator (no VPN, no Bot API); internal gRPC at `validator:9091`. Game login depends only on this. |
|
||||||
| `vpn` + `bot` | sidecar + built (`platform/telegram/Dockerfile`, target `bot`) | Telegram bot, gated to the **`telegram-local`** profile; egresses through the AmneziaWG sidecar and dials the gateway bot-link (mTLS) at `gateway:9443`. The test contour activates the profile; the prod **main** host omits it and runs the bot standalone on its **own host** (`docker-compose.bot.yml`, no VPN — native Bot API egress). |
|
| `vpn` + `bot` | sidecar + built (`platform/telegram/Dockerfile`, target `bot`) | Telegram bot, gated to the **`telegram-local`** profile; egresses through the AmneziaWG sidecar and dials the gateway bot-link (mTLS) at `gateway:9443`. The test contour activates the profile; the prod **main** host omits it and runs the bot standalone on its **own host** (`docker-compose.bot.yml`, no VPN — native Bot API egress). |
|
||||||
| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
|
| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
|
||||||
@@ -45,6 +46,18 @@ 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`
|
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
||||||
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
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`,
|
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
|
||||||
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
|
`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
|
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
|
||||||
@@ -61,7 +74,8 @@ compose binds from this directory.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
||||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
|
| `TELEGRAM_MINIAPP_URL` | 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`. |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||||
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||||
@@ -80,11 +94,11 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||||
| `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`. |
|
| `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. |
|
||||||
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
| `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. |
|
| `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. |
|
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||||
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
|
| `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_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
| `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_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`. |
|
| `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`. |
|
||||||
@@ -92,9 +106,28 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||||
|
| `VITE_VK_APP_LINK` | variable (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). |
|
| `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 five `VITE_*` are **build-args** baked into the gateway and landing images at
|
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
||||||
build time (both targets share one UI build stage — keep the args identical so it is
|
build time (both targets share one UI build stage — keep the args identical so it is
|
||||||
built once), so changing them requires a rebuild (`--build`), not just a restart.
|
built once), so changing them requires a rebuild (`--build`), not just a restart.
|
||||||
|
|
||||||
@@ -105,7 +138,8 @@ at each other on the `internal` network — listed here so they are not mistaken
|
|||||||
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
|
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
|
||||||
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
||||||
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
|
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
|
||||||
bot-link relay `gateway:9092`), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
|
bot-link relay `gateway:9092`), `BACKEND_RENDERER_URL` (→ `renderer:8090`, the image-render
|
||||||
|
sidecar), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
|
||||||
mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
|
mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
|
||||||
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
|
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
|
||||||
deploy), and all services' `*_OTEL_*_EXPORTER=otlp` →
|
deploy), and all services' `*_OTEL_*_EXPORTER=otlp` →
|
||||||
@@ -121,14 +155,13 @@ intentionally **unset** — caddy owns `/_gm` in the contour.
|
|||||||
|
|
||||||
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
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
|
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
||||||
a build-time input with **no default** in the images, so it is set in exactly two places to
|
a build-time input with **no default** in the images. It is a single Gitea repo variable —
|
||||||
move the whole stack — change both to a new release:
|
**`DICT_VERSION`** (unprefixed, shared across contours) — so a release bump is **one edit**:
|
||||||
|
|
||||||
1. **CI tests** — `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
|
- the CI test jobs read it via `.gitea/workflows/ci.yaml`'s top-level
|
||||||
download that dawg).
|
`env.DICT_VERSION: ${{ vars.DICT_VERSION }}` (the unit/integration jobs download that dawg), and
|
||||||
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
|
- both deploy jobs feed the same variable to `compose` as the `DICT_VERSION` build-arg that
|
||||||
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
|
bakes a **fresh** volume's seed.
|
||||||
`DICT_VERSION`).
|
|
||||||
|
|
||||||
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
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
|
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
||||||
@@ -157,6 +190,16 @@ 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 →
|
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).
|
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
|
**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` →
|
`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
|
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
|
||||||
@@ -189,16 +232,31 @@ together with the fresh CA.
|
|||||||
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
|
**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
|
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
|
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
|
||||||
players arrive.
|
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.
|
||||||
|
|
||||||
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
|
**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_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
||||||
TELEGRAM_PROMO_BOT_TOKEN, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
|
||||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; variables:
|
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
|
||||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
BOTLINK_BOT_KEY}`; variables:
|
||||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
|
||||||
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME}`.
|
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).
|
||||||
|
|
||||||
## Host-side setup (outside this repo)
|
## Host-side setup (outside this repo)
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,11 @@ scrabble_base_dir: /opt/scrabble
|
|||||||
# the host's own containers (and any ad-hoc runs) rotate identically.
|
# the host's own containers (and any ad-hoc runs) rotate identically.
|
||||||
docker_log_max_size: "10m"
|
docker_log_max_size: "10m"
|
||||||
docker_log_max_file: "3"
|
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,6 +150,57 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
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 --------------------------------------------------------
|
# --- Deploy directories --------------------------------------------------------
|
||||||
|
|
||||||
- name: Create the scrabble base directories
|
- name: Create the scrabble base directories
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 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
|
||||||
+34
-1
@@ -33,6 +33,39 @@
|
|||||||
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
||||||
header Alt-Svc clear
|
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.
|
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
|
||||||
@gm path /_gm /_gm/*
|
@gm path /_gm /_gm/*
|
||||||
handle @gm {
|
handle @gm {
|
||||||
@@ -53,7 +86,7 @@
|
|||||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /scrabble.edge.v1.Gateway/*
|
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
|
||||||
handle @gateway {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081 {
|
reverse_proxy gateway:8081 {
|
||||||
header_up -X-Scrabble-Honeypot
|
header_up -X-Scrabble-Honeypot
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!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>
|
||||||
@@ -50,6 +50,13 @@ services:
|
|||||||
limits:
|
limits:
|
||||||
memory: 384M
|
memory: 384M
|
||||||
|
|
||||||
|
renderer:
|
||||||
|
image: ${REGISTRY:?set REGISTRY}/scrabble-renderer:${TAG:?set TAG}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 160M
|
||||||
|
|
||||||
validator:
|
validator:
|
||||||
image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG}
|
image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG}
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
+113
-4
@@ -61,6 +61,36 @@ services:
|
|||||||
memory: 512M
|
memory: 512M
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
|
|
||||||
|
# The finished-game image-render sidecar: internal-only, called by the backend for the
|
||||||
|
# PNG export artifact. It runs the same ui/src/lib/gameimage.ts the web client
|
||||||
|
# unit-tests, on skia-canvas (renderer/README.md). node:22-slim carries a shell, so —
|
||||||
|
# uniquely among the built services — it has a real container healthcheck the backend's
|
||||||
|
# depends_on gates on.
|
||||||
|
renderer:
|
||||||
|
container_name: scrabble-renderer
|
||||||
|
image: scrabble-renderer:latest
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: renderer/Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: ${APP_VERSION:-dev}
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
environment:
|
||||||
|
RENDERER_PORT: "8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
# A render peaks well under 100 MiB (a 2-MP canvas + skia); idle sits near 60 MiB.
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "1.0"
|
||||||
|
memory: 192M
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
container_name: scrabble-backend
|
container_name: scrabble-backend
|
||||||
image: scrabble-backend:latest
|
image: scrabble-backend:latest
|
||||||
@@ -80,9 +110,15 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
renderer:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
# search_path=backend matches the migrations (00001 creates the schema).
|
# search_path=backend matches the migrations (00001 creates the schema).
|
||||||
BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
|
BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
|
||||||
|
# The finished-game export: the render sidecar address + the HMAC key signing the
|
||||||
|
# public download URLs (deploy env: TEST_/PROD_EXPORT_SIGN_KEY).
|
||||||
|
BACKEND_RENDERER_URL: http://renderer:8090
|
||||||
|
BACKEND_EXPORT_SIGN_KEY: ${EXPORT_SIGN_KEY:?set EXPORT_SIGN_KEY — the export download URL signing secret}
|
||||||
# The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom
|
# The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom
|
||||||
# for bursts. Postgres (2 cores / 512 MiB) handles it.
|
# for bursts. Postgres (2 cores / 512 MiB) handles it.
|
||||||
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
|
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
|
||||||
@@ -100,6 +136,25 @@ services:
|
|||||||
# GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the
|
# GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the
|
||||||
# cgroup quota (the runtime otherwise sees all of the host's cores).
|
# cgroup quota (the runtime otherwise sees all of the host's cores).
|
||||||
GOMAXPROCS: "2"
|
GOMAXPROCS: "2"
|
||||||
|
# Transactional email (confirm-codes) via the shared Selectel relay. An empty
|
||||||
|
# host makes the backend log codes instead of sending them (dev default). TLS is
|
||||||
|
# implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it
|
||||||
|
# (ssl|starttls) — needed for Selectel's non-standard ports (1127 = SSL, 1126 =
|
||||||
|
# STARTTLS). The From uses the prod domain (Selectel only accepts the verified
|
||||||
|
# sender domain), so it is the same on every contour. PUBLIC_BASE_URL is the
|
||||||
|
# canonical origin for links in the email (never a request Host header) —
|
||||||
|
# required whenever the relay host is set.
|
||||||
|
BACKEND_SMTP_HOST: ${SMTP_RELAY_HOST:-}
|
||||||
|
BACKEND_SMTP_PORT: ${SMTP_RELAY_PORT:-465}
|
||||||
|
BACKEND_SMTP_TLS: ${SMTP_RELAY_TLS:-}
|
||||||
|
BACKEND_SMTP_USERNAME: ${SMTP_RELAY_USER:-}
|
||||||
|
BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
|
||||||
|
BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost}
|
||||||
|
BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||||
|
# 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 dictionary lives on a named volume seeded from the image on first boot
|
||||||
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
||||||
# inherits). The admin console writes new version subdirectories here, and the
|
# inherits). The admin console writes new version subdirectories here, and the
|
||||||
@@ -134,6 +189,9 @@ services:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
|
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
||||||
|
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
|
||||||
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
||||||
@@ -151,6 +209,14 @@ services:
|
|||||||
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
||||||
# the VK auth path (auth.vk is then unregistered).
|
# the VK auth path (auth.vk is then unregistered).
|
||||||
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
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
|
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
||||||
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
||||||
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
||||||
@@ -161,10 +227,12 @@ services:
|
|||||||
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
||||||
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
||||||
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
|
# 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
|
# honeypot/honeytoken. Off by default: it bans by client IP, which is only real
|
||||||
# real in prod — the test contour arrives as one shared NAT address, so a ban
|
# in prod — the test contour arrives as one shared NAT address, so a ban there
|
||||||
# there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
|
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
|
||||||
# these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
|
# 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.
|
||||||
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||||
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
@@ -207,6 +275,9 @@ services:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
|
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
||||||
|
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
|
||||||
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -357,6 +428,11 @@ services:
|
|||||||
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
||||||
volumes:
|
volumes:
|
||||||
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
- ${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
|
- caddy-data:/data
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -444,6 +520,21 @@ services:
|
|||||||
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
|
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
|
||||||
# dashboards poll and do not need Live.
|
# dashboards poll and do not need Live.
|
||||||
GF_LIVE_MAX_CONNECTIONS: "0"
|
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:
|
volumes:
|
||||||
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
|
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
|
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
|
||||||
@@ -494,6 +585,24 @@ services:
|
|||||||
memory: 64M
|
memory: 64M
|
||||||
networks: [internal]
|
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:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
name: scrabble-internal
|
name: scrabble-internal
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"tags": ["scrabble"],
|
"tags": ["scrabble"],
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"schemaVersion": 39,
|
"schemaVersion": 39,
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"refresh": "30s",
|
"refresh": "30s",
|
||||||
"time": { "from": "now-7d", "to": "now" },
|
"time": { "from": "now-7d", "to": "now" },
|
||||||
"panels": [
|
"panels": [
|
||||||
@@ -29,6 +29,49 @@
|
|||||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
"targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }]
|
"targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "App opens vs dictionary fills (cumulative)",
|
||||||
|
"description": "Local move-preview adoption. App opens are client cold starts; dictionary fills are IndexedDB downloads of a dawg. The gap between them is how many launches reuse an already-cached dictionary. Client-reported, best-effort (a batched beacon), so slightly lossy.",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "sum(local_eval_cold_start_total)", "legendFormat": "app opens (cold start)" },
|
||||||
|
{ "refId": "B", "expr": "sum(local_eval_dict_load_total{result=\"fetched\"})", "legendFormat": "dictionary fills (IndexedDB)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Dictionary loads by result (rate)",
|
||||||
|
"description": "Client dictionary loads for the local preview, by tier: fetched (network download), cache_hit (IndexedDB), miss (a warm-up that failed or timed out — trips the bad-connection breaker).",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_dict_load_total[1h])) by (result)", "legendFormat": "{{result}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Move previews by path (rate)",
|
||||||
|
"description": "Where the move preview is computed: local (on-device, the accelerator) vs network (the fallback to game.evaluate). The local share is the backend load shed.",
|
||||||
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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}}" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# 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.' }
|
||||||
@@ -42,6 +42,15 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}"
|
|||||||
PG_USER="${POSTGRES_USER:-scrabble}"
|
PG_USER="${POSTGRES_USER:-scrabble}"
|
||||||
PG_DB="${POSTGRES_DB:-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; }
|
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
|
||||||
export REGISTRY
|
export REGISTRY
|
||||||
# otelcol joins the host docker group to read the socket; the GID varies per host.
|
# otelcol joins the host docker group to read the socket; the GID varies per host.
|
||||||
@@ -119,6 +128,13 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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.
|
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
|
||||||
if [ "$MIGRATION" = 1 ]; then
|
if [ "$MIGRATION" = 1 ]; then
|
||||||
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
||||||
@@ -134,6 +150,7 @@ fi
|
|||||||
|
|
||||||
# Roll one service at a time, least -> most dependent; any failure rolls everything back.
|
# Roll one service at a time, least -> most dependent; any failure rolls everything back.
|
||||||
roll postgres health_postgres || { rollback; exit 1; }
|
roll postgres health_postgres || { rollback; exit 1; }
|
||||||
|
roll renderer health_running scrabble-renderer || { rollback; exit 1; }
|
||||||
roll backend health_backend || { rollback; exit 1; }
|
roll backend health_backend || { rollback; exit 1; }
|
||||||
roll gateway health_running scrabble-gateway || { rollback; exit 1; }
|
roll gateway health_running scrabble-gateway || { rollback; exit 1; }
|
||||||
roll landing health_landing || { rollback; exit 1; }
|
roll landing health_landing || { rollback; exit 1; }
|
||||||
|
|||||||
@@ -23,3 +23,21 @@ scrape_configs:
|
|||||||
- job_name: node
|
- job_name: node
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["node_exporter:9100"]
|
- 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
|
||||||
|
|||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/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
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/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
|
||||||
+171
-12
@@ -232,21 +232,79 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
|
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
|
||||||
robot opponent (§7). The **email confirm-code flow** binds an email to the
|
robot opponent (§7). The **email confirm-code flow** binds an email to the
|
||||||
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
|
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
|
||||||
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
|
TTL, ≤ 5 attempts) is sent as a **branded HTML + plain-text email** through a
|
||||||
development log mailer when none is configured) and, once verified, attaches a
|
`Mailer` seam (go-mail over the shared relay — TLS chosen by port, implicit on
|
||||||
confirmed email identity. Accounts and identities use application-generated
|
465 and STARTTLS otherwise, or forced by config for a non-standard port; the
|
||||||
|
server certificate validated against the system roots, no client certificate; a
|
||||||
|
development log mailer when no relay is configured) and, once
|
||||||
|
verified, attaches a confirmed email identity. Sends are throttled per recipient
|
||||||
|
(a 60-second cooldown and a five-per-hour cap). Links in the email are built from
|
||||||
|
a configured public base URL, never a request Host header (anti-injection). 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. A login requested from an installed **PWA** omits the deeplink
|
||||||
|
entirely — its email carries the code only, typed in the same window, since the link would open
|
||||||
|
in a separate browser whose minted session cannot reach the PWA. 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
|
||||||
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
|
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
|
||||||
payment; no purchase flow yet) is carried on the account and ORed on a merge.
|
payment; no purchase flow yet) is carried on the account and ORed on a merge.
|
||||||
- **Linking** is initiated from an authenticated profile and proves
|
- **Linking** is initiated from an authenticated profile and proves
|
||||||
control of the identity before attaching it: **email** through the confirm-code
|
control of the identity before attaching it: **email** through the confirm-code
|
||||||
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
||||||
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
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`). The
|
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
|
||||||
request step **always** sends/accepts the proof (no pre-send "already taken"
|
request step **always** sends/accepts the proof (no pre-send "already taken"
|
||||||
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
||||||
is revealed **only after** the proof is verified and is performed behind an
|
is revealed **only after** the proof is verified and is performed behind an
|
||||||
explicit, irreversible confirmation. A free identity is simply attached (and a
|
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
|
||||||
guest is promoted to durable, clearing `is_guest`).
|
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**
|
- **Merge** retires the account that owns the linked identity into the **current**
|
||||||
account, in a single transaction (`internal/accountmerge`): statistics summed
|
account, in a single transaction (`internal/accountmerge`): statistics summed
|
||||||
(counters incl. moves/hints added, max points kept, and the per-variant best moves
|
(counters incl. moves/hints added, max points kept, and the per-variant best moves
|
||||||
@@ -785,11 +843,40 @@ the same rows and is likewise self-contained — we ship our own writer (the sol
|
|||||||
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||||||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||||||
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
||||||
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
|
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
|
||||||
otherwise), so an in-progress journal is never leaked mid-play; the client
|
otherwise), so an in-progress journal is never leaked mid-play.
|
||||||
shares the `.gcg` file via the Web Share API where available; an Android in-app WebView
|
|
||||||
(Telegram / VK) has no Web Share and silently ignores an `<a download>`, so there it copies the GCG
|
**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text
|
||||||
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
|
and the rendered **PNG of the final position** — travel one uniform route on every
|
||||||
|
platform: the client calls the authenticated `game.export_url` op, the backend mints a
|
||||||
|
**relative, HMAC-signed, short-lived path**
|
||||||
|
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL,
|
||||||
|
`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no
|
||||||
|
service ever needs to know the public host) and hands it to the best affordance
|
||||||
|
each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the
|
||||||
|
chooser there is the native showPopup — activation-free bridge calls end to end),
|
||||||
|
Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
|
||||||
|
the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both
|
||||||
|
formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
|
||||||
|
DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a
|
||||||
|
mobile browser, or a plain anchor download (desktop browsers and the VK desktop
|
||||||
|
iframe). The gateway serves the bytes via
|
||||||
|
`http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager
|
||||||
|
hangs on chunked bodies of unknown length). The GET is the gateway's
|
||||||
|
**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the
|
||||||
|
per-IP public rate limiter): the native download calls carry no cookies or headers, so
|
||||||
|
the URL's signature — verified by the backend on its `/api/v1/public` group in constant
|
||||||
|
time, every failure a uniform 404 — is the whole grant, and minting requires an
|
||||||
|
authenticated caller on a finished game. The PNG is rasterized on demand by the
|
||||||
|
**`renderer` sidecar** (internal-only Node + skia-canvas running the same
|
||||||
|
`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift;
|
||||||
|
`renderer/README.md`); the backend rebuilds the render payload from the journal +
|
||||||
|
`engine.AlphabetTable`, and the client's date locale, IANA time zone and UI-localized
|
||||||
|
non-play labels ride the signed URL so the server render matches the player's
|
||||||
|
presentation. Nothing is stored: the artifact is re-derived from the immutable
|
||||||
|
finished journal on each GET. The only degraded platform is a legacy Telegram client
|
||||||
|
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
||||||
|
image option is not offered.
|
||||||
|
|
||||||
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
||||||
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||||
@@ -895,7 +982,25 @@ 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
|
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),
|
`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*
|
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.
|
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.
|
||||||
|
|
||||||
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
> 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
|
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||||
@@ -926,6 +1031,21 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the
|
both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the
|
||||||
stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour
|
stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour
|
||||||
host resolved only the root cgroup — a separate-XFS `/var/lib/docker`.)
|
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. The digest carries **no admin-console link** — an admin URL must
|
||||||
|
never travel in an email, where a mail provider could cache or index it; the operator opens the
|
||||||
|
console directly. Both paths are inert unless configured.
|
||||||
- Per-request server-side timing via gin middleware from day one (the access log
|
- 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
|
carries method, route, status, latency and the active trace id). A
|
||||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||||
@@ -955,6 +1075,31 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
distinct accounts that performed an authenticated edge action in the window. The
|
distinct accounts that performed an authenticated edge action in the window. The
|
||||||
gauge is single-process by design (single-instance MVP, §10): it is correct for one
|
gauge is single-process by design (single-instance MVP, §10): it is correct for one
|
||||||
gateway, resets on restart, and is a live operational figure, not a billing count.
|
gateway, resets on restart, and is a live operational figure, not a billing count.
|
||||||
|
- **Local-preview adoption (client-reported):** three gateway counters measure uptake of
|
||||||
|
the local move-preview accelerator (§5), fed by a small, best-effort client beacon
|
||||||
|
(`POST /metrics/local-eval`, session-gated) that batches counter deltas and posts them
|
||||||
|
on a 60 s timer and when the app is backgrounded — **never on the gameplay path** (the
|
||||||
|
in-app counters are plain in-memory increments; only the periodic flush touches the
|
||||||
|
network, fire-and-forget). `local_eval_cold_start_total` counts app cold starts (the
|
||||||
|
adoption denominator); `local_eval_dict_load_total` (`result` = fetched/cache_hit/miss)
|
||||||
|
splits a dawg download that fills IndexedDB from a cache hit and from a warm-up that
|
||||||
|
failed or timed out; `local_eval_preview_total` (`path` = local/network) is the share of
|
||||||
|
previews computed on-device versus the network `evaluate` fallback — the backend load
|
||||||
|
shed. It answers the owner's adoption question — how often the app opens versus how often
|
||||||
|
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
|
||||||
|
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
|
||||||
|
a game input.
|
||||||
|
- **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
|
- **Rate-limit observability:** every limiter rejection increments the gateway
|
||||||
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
||||||
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
||||||
@@ -1085,7 +1230,21 @@ 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
|
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||||
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
|
static file serving and never reaches the Go edge. The landing shell carries the site's
|
||||||
|
static SEO head — Russian title/description, the Open Graph card (Telegram/VK link
|
||||||
|
previews), JSON-LD and a canonical link — with absolute URLs pinned to the production
|
||||||
|
origin, so the test contour canonicalises to production instead of being indexed as a
|
||||||
|
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
|
||||||
|
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||||
|
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
||||||
|
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
||||||
|
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from
|
||||||
|
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the
|
||||||
|
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
|
||||||
|
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
|
||||||
|
installability requirement (a registered SW, needed for install on Android) and is the single
|
||||||
|
growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type
|
||||||
|
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||||
|
|||||||
+104
-17
@@ -16,14 +16,26 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge
|
|||||||
real-time in-app updates, switching interface language (en/ru) and theme, and a
|
real-time in-app updates, switching interface language (en/ru) and theme, and a
|
||||||
read-only profile. It also handles managing friends (including one-time friend
|
read-only profile. It also handles managing friends (including one-time friend
|
||||||
codes) and blocks, friend-game invitations, editing the profile and binding an
|
codes) and blocks, friend-game invitations, editing the profile and binding an
|
||||||
email, the statistics screen, and the in-game history viewer with GCG export.
|
email, the statistics screen, and the in-game history viewer with GCG / PNG-image export.
|
||||||
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
||||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||||
A public **landing page** at the site root introduces the game, switches language and
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
theme, and links to the Telegram game channel, the VK Mini App and the **web version**
|
||||||
|
(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at
|
||||||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
(it follows the system scheme, not the saved preference); its language choice is saved. The
|
||||||
|
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
|
||||||
|
render with arbitrary languages, so detection would make the indexed content
|
||||||
|
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
||||||
|
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
||||||
|
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
||||||
|
shell is `noindex` — the landing is the only indexable page.
|
||||||
|
|
||||||
|
On the plain web the client is an **installable PWA**: a logged-out player sees an install
|
||||||
|
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
|
||||||
|
home screen / desktop in one tap where the browser supports it (Chromium), or shows manual
|
||||||
|
Add-to-Home-Screen steps on iOS Safari; it is hidden once installed and inside the Mini Apps.
|
||||||
|
|
||||||
### First-run onboarding
|
### First-run onboarding
|
||||||
The first time a player opens the app it walks them through the interface with a light
|
The first time a player opens the app it walks them through the interface with a light
|
||||||
@@ -55,6 +67,18 @@ 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
|
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
|
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.
|
"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. A sign-in requested from an **installed PWA** omits
|
||||||
|
this link: the email carries only the code, entered in the same window, since the link would open
|
||||||
|
in a separate browser the app cannot reach.
|
||||||
Telegram runs a **single bot**: every player uses
|
Telegram runs a **single bot**: every player uses
|
||||||
the same bot, and all of its chat and out-of-app notifications are written in the
|
the same bot, and all of its chat and out-of-app notifications are written in the
|
||||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
@@ -80,10 +104,6 @@ reconnect), and pending reads resume on their own — the interface stays usable
|
|||||||
flashing a red banner each time.
|
flashing a red banner each time.
|
||||||
|
|
||||||
### Accounts, linking & merge
|
### 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
|
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
|
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
|
who links their first identity becomes a durable account. The "already taken" status
|
||||||
@@ -95,6 +115,30 @@ 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
|
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.
|
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
|
### Lobby & matchmaking
|
||||||
On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles
|
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
|
spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the
|
||||||
@@ -312,12 +356,44 @@ silently ignores their messages) or clear a thread's messages. This is independe
|
|||||||
Feedback above — it serves people who write to the bot directly rather than through the app.
|
Feedback above — it serves people who write to the bot directly rather than through the app.
|
||||||
|
|
||||||
### History & statistics
|
### History & statistics
|
||||||
Finished games are archived in a dictionary-independent form and exportable to
|
Finished games are archived in a dictionary-independent form and exportable in
|
||||||
GCG; the export is offered **only once a game is finished**, and never for an
|
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
|
||||||
honest-AI practice game (a live game's export would leak the move journal; an AI
|
the final position; the export is offered **only once a game is finished**, and
|
||||||
game is throwaway). The client shares the `.gcg` file where the platform supports
|
never for an honest-AI practice game (a live game's export would leak the move
|
||||||
it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file
|
journal; an AI game is throwaway). The format chooser is Telegram's **native popup**
|
||||||
download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. Statistics (durable accounts only):
|
on Telegram Android/desktop (safe there: that chain is all bridge calls, which need
|
||||||
|
no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery
|
||||||
|
is the OS share sheet, which a native-popup callback cannot open, and VK has no
|
||||||
|
native chooser.
|
||||||
|
|
||||||
|
The **image** is rendered on the server (the internal render sidecar runs the same
|
||||||
|
drawing module the web client tests; always the light theme): the final board with
|
||||||
|
classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour
|
||||||
|
fills, no text labels — and a compact per-seat scoresheet on the right: each seat's
|
||||||
|
name and final score in the header (🏆 by the winner), then one row per move
|
||||||
|
carrying the main word's classic coordinate (an across play is row-first, `8G`; a
|
||||||
|
down play column-first, `H8` — the GCG convention), the word and its points; extra
|
||||||
|
words of a multi-word play ride a smaller second line, non-play moves show as
|
||||||
|
localized notes (pass/exchange/resign/timeout), and a closing ± row shows the
|
||||||
|
endgame rack settlement when there was one (the final scores are authoritative —
|
||||||
|
running totals alone do not include it). The footer stamps the site host and the
|
||||||
|
finish date in the device locale. The scoresheet typography is fixed; a long game
|
||||||
|
stretches the board (never below its minimum) so the image carries no dead space.
|
||||||
|
|
||||||
|
Delivery is **one signed, short-lived link for both formats on every platform**,
|
||||||
|
handed to the best affordance each platform has (each branch verified on-device):
|
||||||
|
Telegram Android/desktop use Telegram's download dialog (whose own preview shares
|
||||||
|
onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS
|
||||||
|
takes both formats through VK's download into its native share flow, while on the
|
||||||
|
**VK Android client** — whose downloader hangs — the image opens in VK's native
|
||||||
|
photo viewer (saving from its controls) and the GCG copies to the clipboard (the
|
||||||
|
desktop VK iframe, an ordinary browser, downloads both); a **mobile browser gets
|
||||||
|
the OS share sheet** (nothing lands in Downloads first); a desktop browser
|
||||||
|
downloads the file. The link needs no login to fetch (the platforms' downloaders
|
||||||
|
carry none) and is valid for minutes. The single exception is a legacy Telegram
|
||||||
|
client without the download dialog (pre-Bot API 8.0): there the GCG falls back to
|
||||||
|
the old clipboard copy (with the confirming toast) and the image option is not
|
||||||
|
offered. Statistics (durable accounts only):
|
||||||
wins, losses, draws, max points in a game, and max points for a single move (the
|
wins, losses, draws, max points in a game, and max points for a single move (the
|
||||||
best play, which already includes every word it formed plus the all-tiles bonus). It
|
best play, which already includes every word it formed plus the all-tiles bonus). It
|
||||||
also shows the player's **move count** (their plays — passes and exchanges do not
|
also shows the player's **move count** (their plays — passes and exchanges do not
|
||||||
@@ -417,8 +493,19 @@ 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 —
|
**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.
|
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
|
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), and set
|
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a
|
||||||
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
|
non-default campaign's optional override colours (a native colour picker with a live light-and-dark
|
||||||
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
|
preview of the strip) and mark it urgent, and set the global display **timings** (how long a message
|
||||||
keeps at least one 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).
|
||||||
|
|||||||
+104
-17
@@ -16,15 +16,28 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
профиль только для чтения. Он также включает управление друзьями (в т.ч.
|
профиль только для чтения. Он также включает управление друзьями (в т.ч.
|
||||||
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
|
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
|
||||||
редактирование профиля и привязку email, экран статистики и просмотр истории
|
редактирование профиля и привязку email, экран статистики и просмотр истории
|
||||||
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
|
партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток
|
||||||
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
|
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
|
||||||
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
|
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
|
||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под
|
||||||
|
подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам
|
||||||
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
|
||||||
|
открывается на **русском** — язык браузера не определяется автоматически (роботы
|
||||||
|
рендерят страницу с произвольным языком, и автоопределение сделало бы
|
||||||
|
проиндексированный контент недетерминированным); сохранённый выбор 🌐 по-прежнему
|
||||||
|
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
||||||
|
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
||||||
|
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||||
|
`noindex` — индексируется только посадочная страница.
|
||||||
|
|
||||||
|
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
||||||
|
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
||||||
|
(домашний экран) там, где браузер это умеет (Chromium), либо показывает ручные шаги
|
||||||
|
«На экран „Домой“» в Safari на iOS; он скрыт после установки и внутри Mini App.
|
||||||
|
|
||||||
### Первый запуск: онбординг
|
### Первый запуск: онбординг
|
||||||
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
||||||
@@ -58,7 +71,19 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||||
тихого повтора «не удалось
|
тихого повтора «не удалось
|
||||||
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
|
загрузить» действует и внутри VK.
|
||||||
|
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||||
|
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||||
|
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||||
|
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
|
||||||
|
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
||||||
|
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||||
|
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||||
|
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||||
|
он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного
|
||||||
|
PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка
|
||||||
|
открылась бы в отдельном браузере, недоступном приложению).
|
||||||
|
Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
||||||
@@ -83,10 +108,6 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
рабочим вместо красного баннера каждый раз.
|
рабочим вместо красного баннера каждый раз.
|
||||||
|
|
||||||
### Аккаунты, привязка и слияние
|
### Аккаунты, привязка и слияние
|
||||||
_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он
|
|
||||||
вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание
|
|
||||||
ниже — на этот случай._
|
|
||||||
|
|
||||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||||
@@ -98,6 +119,29 @@ _Вход сейчас только через провайдера, поэто
|
|||||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
||||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||||
|
|
||||||
|
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||||
|
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||||
|
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
|
||||||
|
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
|
||||||
|
показывается только плашка другого провайдера. Привязка провайдера, уже
|
||||||
|
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
||||||
|
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||||
|
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||||
|
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
||||||
|
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
||||||
|
старый. Новый адрес, уже принадлежащий другому аккаунту, отклоняется нейтральным
|
||||||
|
«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает
|
||||||
|
аккаунты и не раскрывает чужой.
|
||||||
|
|
||||||
|
Игрок может **удалить аккаунт**. Это удаление с юридическим удержанием, а не стирание:
|
||||||
|
аккаунт деактивируется, живые поверхности обезличиваются (соперники видят «[Deleted]»),
|
||||||
|
способы входа освобождаются под повторную регистрацию, сессии отзываются — но досье
|
||||||
|
(какие креды были привязаны, последний вход, сообщения игрока) сохраняется для оператора и
|
||||||
|
чистится через два года. Подтверждение удаления — код на почту (если у аккаунта есть
|
||||||
|
e-mail) либо ввод фразы. Активные игры форфейтятся, чтобы не бросать соперников; одиночные
|
||||||
|
игры против ИИ удаляются, а игры с людьми сохраняются под обезличенным именем, чат игрока
|
||||||
|
остаётся. Если снова открыть приложение после удаления — создаётся новый аккаунт.
|
||||||
|
|
||||||
### Лобби и подбор
|
### Лобби и подбор
|
||||||
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
|
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
|
||||||
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
|
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
|
||||||
@@ -320,12 +364,44 @@ Telegram.
|
|||||||
канал для тех, кто пишет боту напрямую, а не через приложение.
|
канал для тех, кто пишет боту напрямую, а не через приложение.
|
||||||
|
|
||||||
### История и статистика
|
### История и статистика
|
||||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
Завершённые партии архивируются в независимом от словаря виде и экспортируются в
|
||||||
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
|
||||||
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
|
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
|
||||||
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает;
|
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
|
||||||
в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG
|
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на
|
||||||
в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов):
|
Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов,
|
||||||
|
которым user activation не нужна) и собственный модал приложения в остальных
|
||||||
|
случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из
|
||||||
|
коллбека нативного попапа не открыть, а у VK нативного чузера нет.
|
||||||
|
|
||||||
|
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
|
||||||
|
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
|
||||||
|
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
|
||||||
|
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
|
||||||
|
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
|
||||||
|
строке на ход: классическая координата главного слова (горизонтальный ход —
|
||||||
|
цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и
|
||||||
|
его очки; дополнительные слова мультисловного хода — второй мелкой строкой,
|
||||||
|
не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а
|
||||||
|
замыкающая строка ± показывает концовочную поправку за рэк, когда она была
|
||||||
|
(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта
|
||||||
|
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
|
||||||
|
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
|
||||||
|
|
||||||
|
Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
|
||||||
|
платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
|
||||||
|
ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
|
||||||
|
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
|
||||||
|
скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный
|
||||||
|
share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в
|
||||||
|
нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер
|
||||||
|
(десктопный iframe VK — обычный браузер — скачивает оба); **мобильный браузер
|
||||||
|
получает системную share-шторку** (ничего не оседает в Загрузках); десктопный
|
||||||
|
браузер скачивает файл. Ссылка не требует логина при
|
||||||
|
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
|
||||||
|
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
|
||||||
|
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
|
||||||
|
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
|
||||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||||
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||||
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||||
@@ -428,8 +504,19 @@ high-rate флага. С карточки пользователя операт
|
|||||||
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
||||||
кампания с несколькими сообщениями показывает их по очереди.
|
кампания с несколькими сообщениями показывает их по очереди.
|
||||||
|
|
||||||
|
Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или
|
||||||
|
служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один
|
||||||
|
применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы
|
||||||
|
кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также
|
||||||
|
пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам,
|
||||||
|
владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает
|
||||||
|
**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих
|
||||||
|
системных уведомлений (тех.работы, сбои), а не для рекламы.
|
||||||
|
|
||||||
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
||||||
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
||||||
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
|
их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты
|
||||||
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
|
на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько
|
||||||
всегда остаётся хотя бы одно сообщение.
|
держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между
|
||||||
|
сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она
|
||||||
|
остаётся простой (без цветов, никогда не срочная).
|
||||||
|
|||||||
+13
-3
@@ -19,8 +19,15 @@ tests or touching CI.
|
|||||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||||
invitations section, the stats screen, profile editing, and the GCG export's
|
invitations section, the stats screen, profile editing, and the export chooser's
|
||||||
finished-only visibility.
|
finished-only visibility + its signed-URL download flow (route-intercepted).
|
||||||
|
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||||
|
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||||
|
real self-played 35-move game) must rasterize to a plausible PNG
|
||||||
|
(`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout,
|
||||||
|
notation) stay unit-tested in `ui/` — the sidecar test guards only the
|
||||||
|
skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across
|
||||||
|
hosts).
|
||||||
- **Local-eval conformance** — the client's on-device move preview (the ported
|
- **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
|
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
|
authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit
|
||||||
@@ -62,7 +69,10 @@ tests or touching CI.
|
|||||||
content and block-visibility rules, the nudge turn/rate-limit rules, the
|
content and block-visibility rules, the nudge turn/rate-limit rules, the
|
||||||
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
|
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
|
||||||
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
|
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
|
||||||
email, expiry and attempt-cap) with a fixture mailer. It also covers the
|
email, expiry and attempt-cap, and the guest-until-confirmed login) with a fixture
|
||||||
|
mailer. The branded email template render (en/ru subject, code and body) and the
|
||||||
|
per-recipient send-rate limiter (cooldown + hourly cap) are pure account-package
|
||||||
|
unit tests, needing no database. It also covers the
|
||||||
**befriend-an-opponent** gate (a request needs a shared game), the **permanent
|
**befriend-an-opponent** gate (a request needs a shared game), the **permanent
|
||||||
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
|
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
|
||||||
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
|
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
|
||||||
|
|||||||
+48
-10
@@ -214,8 +214,7 @@ e2e and the screenshots.
|
|||||||
**Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬
|
**Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬
|
||||||
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
|
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
|
||||||
game with "multiple words per turn" off) centres a **"One word per turn"** label between
|
game with "multiple words per turn" off) centres a **"One word per turn"** label between
|
||||||
those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that
|
those two icons; standard games show no label. Each **opponent**'s card also gains two
|
||||||
yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two
|
|
||||||
edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
|
edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
|
||||||
disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
|
disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
|
||||||
swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while
|
swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while
|
||||||
@@ -316,6 +315,19 @@ 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
|
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
||||||
and a long message does not scroll.
|
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`)
|
## Result / status iconography (`lib/result.ts`)
|
||||||
|
|
||||||
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
||||||
@@ -404,15 +416,41 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
|
lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
|
||||||
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
|
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
|
||||||
Safari.
|
Safari.
|
||||||
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
|
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid
|
||||||
(the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history
|
(the word(s) and the move score, no running total); the 📤 in the history header appears
|
||||||
header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app
|
only once the game is finished — never in an honest-AI game (throwaway practice) — and
|
||||||
WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download —
|
opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
|
||||||
and appears only once the game is finished — and
|
there, since that chain is all bridge calls needing no user activation (a popup callback
|
||||||
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
|
must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's
|
||||||
it closes the history drawer (portrait) and zooms the board out.
|
own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal
|
||||||
|
click's activation; VK has no native chooser; the accent button leads with the image,
|
||||||
|
both options need the connection). Both formats mint the same signed relative link
|
||||||
|
(`game.export_url`), resolved against the app's own origin, then delivered per platform
|
||||||
|
(each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview
|
||||||
|
shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS
|
||||||
|
`VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose
|
||||||
|
DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save
|
||||||
|
works) and the GCG to the clipboard; the VK desktop iframe plain anchor downloads; a
|
||||||
|
mobile browser the OS share sheet (the proven fetch-then-share pattern); a desktop
|
||||||
|
browser an anchor download. A legacy Telegram
|
||||||
|
client without `downloadFile` keeps the old GCG clipboard copy, hides the image option
|
||||||
|
and stays on the app modal. Confirming a resign reveals the full board: it closes the
|
||||||
|
history drawer (portrait) and zooms the board out.
|
||||||
|
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
|
||||||
|
executes; the browser app no longer draws it): always the light palette (pinned constants
|
||||||
|
mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
|
||||||
|
plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left,
|
||||||
|
value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is
|
||||||
|
the scoresheet: per-seat name over the final score (🏆 by the winner), one
|
||||||
|
fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down =
|
||||||
|
column-first `H8`), the main word, points right-aligned; extra words of a multi-word play
|
||||||
|
on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a
|
||||||
|
closing muted ± row for the endgame rack settlement when present. Footer:
|
||||||
|
`hostname · finish date` in the **device** locale. The scoresheet's height drives the
|
||||||
|
board side (never below its minimum): a long game stretches the board, never the
|
||||||
|
typography, so the image has no dead space.
|
||||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||||
offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
|
offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
|
||||||
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||||
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
|
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
|
||||||
row is turn-driven: on your turn the message field shows until your one allowed message is
|
row is turn-driven: on your turn the message field shows until your one allowed message is
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user