From 3a640a17a46865370270e75650a0a648edac3ba2 Mon Sep 17 00:00:00 2001 From: developer Date: Thu, 4 Jun 2026 07:27:49 +0000 Subject: [PATCH] Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts) (#11) --- PLAN.md | 51 +- backend/README.md | 14 +- backend/cmd/backend/main.go | 19 +- backend/internal/account/account.go | 58 +++ .../internal/adminconsole/assets/console.css | 103 ++++ backend/internal/adminconsole/doc.go | 9 + backend/internal/adminconsole/render.go | 101 ++++ backend/internal/adminconsole/render_test.go | 69 +++ .../adminconsole/templates/layout.gohtml | 28 ++ .../templates/pages/broadcast.gohtml | 14 + .../templates/pages/complaint_detail.gohtml | 32 ++ .../templates/pages/complaints.gohtml | 30 ++ .../templates/pages/dashboard.gohtml | 24 + .../templates/pages/dictionary.gohtml | 43 ++ .../templates/pages/game_detail.gohtml | 29 ++ .../adminconsole/templates/pages/games.gohtml | 23 + .../templates/pages/message.gohtml | 7 + .../templates/pages/user_detail.gohtml | 60 +++ .../adminconsole/templates/pages/users.gohtml | 26 + backend/internal/adminconsole/views.go | 200 ++++++++ backend/internal/config/config.go | 23 +- backend/internal/connector/client.go | 58 +++ backend/internal/engine/registry.go | 54 +++ backend/internal/engine/reload_test.go | 119 +++++ backend/internal/game/service.go | 102 ++++ backend/internal/game/store.go | 200 +++++++- backend/internal/game/types.go | 44 +- backend/internal/inttest/admin_test.go | 183 +++++++ .../postgres/jet/backend/model/complaints.go | 24 +- .../postgres/jet/backend/table/complaints.go | 78 +-- .../00008_complaints_resolution.sql | 30 ++ backend/internal/server/handlers.go | 1 - backend/internal/server/handlers_admin.go | 16 - .../internal/server/handlers_admin_console.go | 458 ++++++++++++++++++ backend/internal/server/handlers_test.go | 7 - backend/internal/server/middleware.go | 38 ++ .../server/middleware_console_test.go | 51 ++ backend/internal/server/server.go | 25 +- docs/ARCHITECTURE.md | 37 +- docs/FUNCTIONAL.md | 14 +- docs/FUNCTIONAL_ru.md | 13 +- docs/TESTING.md | 11 + gateway/README.md | 15 +- gateway/cmd/gateway/main.go | 46 +- gateway/internal/admin/admin.go | 28 +- gateway/internal/admin/admin_test.go | 39 +- gateway/internal/config/config.go | 14 +- .../internal/connectsrv/console_mount_test.go | 45 ++ gateway/internal/connectsrv/server.go | 35 +- 49 files changed, 2548 insertions(+), 200 deletions(-) create mode 100644 backend/internal/adminconsole/assets/console.css create mode 100644 backend/internal/adminconsole/doc.go create mode 100644 backend/internal/adminconsole/render.go create mode 100644 backend/internal/adminconsole/render_test.go create mode 100644 backend/internal/adminconsole/templates/layout.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/broadcast.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/complaint_detail.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/complaints.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/dashboard.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/dictionary.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/game_detail.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/games.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/message.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/user_detail.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/users.gohtml create mode 100644 backend/internal/adminconsole/views.go create mode 100644 backend/internal/connector/client.go create mode 100644 backend/internal/engine/reload_test.go create mode 100644 backend/internal/inttest/admin_test.go create mode 100644 backend/internal/postgres/migrations/00008_complaints_resolution.sql delete mode 100644 backend/internal/server/handlers_admin.go create mode 100644 backend/internal/server/handlers_admin_console.go create mode 100644 backend/internal/server/middleware_console_test.go create mode 100644 gateway/internal/connectsrv/console_mount_test.go diff --git a/PLAN.md b/PLAN.md index 3bba161..a3e8e7a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -43,7 +43,7 @@ independent (see ARCHITECTURE §9.1). | 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** | | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** | | 9 | Telegram integration (bot side-service, deep-link, push) | **done** | -| 10 | Admin & dictionary ops (complaint review, version reload) | todo | +| 10 | Admin & dictionary ops (complaint review, version reload) | **done** | | 11 | Account linking & merge | todo | | 12 | Polish (observability, perf with evidence, deploy) | todo | @@ -693,6 +693,51 @@ Open details: deployment target/host; dashboards; load expectations. the sweeper skip when CI ran with `now-1h` inside it) was made deterministic by clearing the test account's away window. +- **Stage 10** (interview + implementation): + - **Admin console = backend-rendered `/_gm`, gateway Basic-Auth** (interview, two + rounds): the owner chose a dedicated web console but, pointing at `../galaxy-game` + and asking to keep it simple, the deliverable is **server-rendered Go + `html/template` + one embedded CSS** (`backend/internal/adminconsole`: a + framework-agnostic renderer + page view-models, `//go:embed` templates/assets, zero + JS, no build step), **not** a SPA. It lives **in the backend** on its own route + `/_gm/*`; the **gateway** (the project's built-in reverse proxy) gates `/_gm/*` with + the existing `GATEWAY_ADMIN_USER/PASSWORD` Basic-Auth on its **public** listener and + proxies **verbatim** to backend `/_gm/*` (mounted on the edge mux below the h2c wrap + so Connect keeps working). This **supersedes Stage 6's** gateway-fronts- + `/api/v1/admin` model: the separate admin port `GATEWAY_ADMIN_ADDR` is dropped (only + the port — user/password stay), the backend `/api/v1/admin` group + `ping` are + removed, and `gateway/internal/admin` is repurposed to the verbatim proxy. The + backend keeps **no operator identity** and no `admin_accounts` table; CSRF on the + console's POSTs is a **same-origin** check (`Origin`/`Referer` vs `Host`, the gateway + preserves the inbound Host) — no token. Discharges Stage 1's "admin bootstrap" (it is + config, not a DB seed). + - **Complaint resolution + dictionary pipeline** (interview): migration **00008** + (+ jetgen) adds `disposition`/`resolution_note`/`resolved_at`/`applied_in_version` + to `complaints` and the deferred `status` CHECK (`open|resolved`) — **discharges + Stage 3's** deferral (no `resolved_by`: operator identity is not tracked). Resolution + sets a disposition (`reject`/`accept_add`/`accept_remove`); accepted complaints are + **derived by query** into a pending dictionary-change list (no new table), stamped + `applied_in_version` once a rebuilt version is loaded. New `game` reads + `ListComplaints`/`GetComplaint`/`CountComplaints`/`ResolveComplaint`/ + `DictionaryChanges`/`MarkChangesApplied`; admin list/count reads + `account.ListAccounts/CountAccounts/Identities` and `game.ListGames/CountGames/ + GameByID`. + - **Dictionary hot-reload = per-version subdir** (interview): the launch version stays + in the flat `BACKEND_DICT_DIR` (CI/dev untouched); a reloaded version `X` loads from + `BACKEND_DICT_DIR/X/` via the new `Registry.LoadAvailable` (present variants only), + and boot re-loads every subdirectory via `engine.OpenWithVersions` so reloaded + versions survive a restart. **Partially addresses TODO-2** (the runtime reload + contract; the offline DAWG generator stays future work). + - **Operator broadcasts** (discharges Stage 9's forward-note): the backend gains its + own connector gRPC client (`backend/internal/connector`, `BACKEND_CONNECTOR_ADDR`, + nil when unset) over the existing `pkg/proto/telegram/v1`; the console messages a + user by `account_id` (backend resolves the Telegram `external_id`) and posts to the + game channel via `SendToUser`/`SendToGameChannel`. + - **Config/CI**: backend adds `BACKEND_CONNECTOR_ADDR`; gateway drops + `GATEWAY_ADMIN_ADDR` (keeps user/password). No new module and no fbs/proto/UI codegen + (the console is server-rendered Go). The Go workflows already span + `./backend/... ./gateway/... ./pkg/...`; integration stays `./backend/...`. + ## Deferred TODOs (cross-stage) - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, @@ -710,7 +755,9 @@ Open details: deployment target/host; dashboards; load expectations. git submodule (the ~0.5–0.7 MB DAWGs are regenerated wholesale and bloat git history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull is a **deploy-time** way to populate the directory, **not** the runtime - dynamic-reload mechanism (Stage 10) — keep the `BACKEND_DICT_DIR` directory as + dynamic-reload mechanism (**implemented in Stage 10**: a per-version subdirectory + `BACKEND_DICT_DIR//` loaded via `Registry.LoadAvailable`, restart-restored by + `engine.OpenWithVersions`) — keep the `BACKEND_DICT_DIR` directory as the runtime contract: a new `.dawg` appears in it and is loaded with `dawg.Load`. - **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a diff --git a/backend/README.md b/backend/README.md index 8646114..f65c337 100644 --- a/backend/README.md +++ b/backend/README.md @@ -73,8 +73,15 @@ uses to route out-of-app push to the Telegram connector, extends the Telegram lo seed a new account's language and display name from the launch fields, and adds migration `00007` (`accounts.notifications_in_app_only`, default true). Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row -with no identity, excluded from statistics. The shared wire contracts live in the -sibling [`../pkg`](../pkg) module. +with no identity, excluded from statistics. **Stage 10** adds the server-rendered +**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`; +the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the +**complaint resolution** lifecycle (migration `00008` adds `disposition`/`resolution_note`/ +`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change +pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR//` +(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a +backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`). The +shared wire contracts live in the sibling [`../pkg`](../pkg) module. ## Package layout @@ -94,6 +101,8 @@ internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver +internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm +internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts) ``` ## Configuration (environment) @@ -123,6 +132,7 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str | `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | | `BACKEND_SMTP_PASSWORD` | — | SMTP password. | | `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | +| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | ## Run diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index e7008d2..76c2a38 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -19,6 +19,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/config" + "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" @@ -92,7 +93,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { } logger.Info("database migrations applied") - registry, err := engine.Open(cfg.Game.DictDir, cfg.Game.DictVersion) + registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion) if err != nil { return fmt.Errorf("load dictionaries: %w", err) } @@ -101,6 +102,19 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.String("dir", cfg.Game.DictDir), zap.String("version", cfg.Game.DictVersion)) + // Stage 10 admin console: an optional backend client to the Telegram connector + // side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty) + // leaves broadcasts disabled — the console shows a "not configured" notice. + var conn *connector.Client + if cfg.ConnectorAddr != "" { + conn, err = connector.New(cfg.ConnectorAddr) + if err != nil { + return fmt.Errorf("dial connector: %w", err) + } + defer func() { _ = conn.Close() }() + logger.Info("connector client ready", zap.String("addr", cfg.ConnectorAddr)) + } + sessions := session.NewService(session.NewStore(db), session.NewCache()) if err := sessions.Warm(ctx); err != nil { return fmt.Errorf("warm session cache: %w", err) @@ -156,6 +170,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Matchmaker: matchmaker, Invitations: invitations, Emails: emails, + Registry: registry, + DictDir: cfg.Game.DictDir, + Connector: conn, }) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 5788c90..2629c78 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -67,6 +67,16 @@ type Account struct { UpdatedAt time.Time } +// Identity is one of an account's platform/email identities, surfaced on the +// admin account-detail view. ExternalID is the platform user id (or the email +// address for an email identity); Confirmed tracks the email confirm-code flow. +type Identity struct { + Kind string + ExternalID string + Confirmed bool + CreatedAt time.Time +} + // Store is the Postgres-backed query surface for accounts and identities. type Store struct { db *sql.DB @@ -187,6 +197,54 @@ func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kin return row.ExternalID, nil } +// Identities returns the account's platform/email identities, oldest first, for +// the admin account-detail view. +func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity, error) { + stmt := postgres.SELECT(table.Identities.AllColumns). + FROM(table.Identities). + WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). + ORDER_BY(table.Identities.CreatedAt.ASC()) + var rows []model.Identities + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("account: list identities %s: %w", accountID, err) + } + out := make([]Identity, 0, len(rows)) + for _, r := range rows { + out = append(out, Identity{Kind: r.Kind, ExternalID: r.ExternalID, Confirmed: r.Confirmed, CreatedAt: r.CreatedAt}) + } + return out, nil +} + +// ListAccounts returns accounts for the admin user list, newest first, paginated +// by limit and offset. +func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) { + stmt := postgres.SELECT(table.Accounts.AllColumns). + FROM(table.Accounts). + ORDER_BY(table.Accounts.CreatedAt.DESC()). + LIMIT(int64(limit)). + OFFSET(int64(offset)) + var rows []model.Accounts + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("account: list accounts: %w", err) + } + out := make([]Account, 0, len(rows)) + for _, r := range rows { + out = append(out, modelToAccount(r)) + } + return out, nil +} + +// CountAccounts returns the total number of accounts, for admin-list pagination. +func (s *Store) CountAccounts(ctx context.Context) (int, error) { + stmt := postgres.SELECT(postgres.COUNT(table.Accounts.AccountID).AS("count")). + FROM(table.Accounts) + var dest struct{ Count int64 } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return 0, fmt.Errorf("account: count accounts: %w", err) + } + return int(dest.Count), nil +} + // findByIdentity joins identities to accounts and returns the matching account, // or ErrNotFound. func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) { diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css new file mode 100644 index 0000000..780d1cd --- /dev/null +++ b/backend/internal/adminconsole/assets/console.css @@ -0,0 +1,103 @@ +/* Admin console stylesheet. Deliberately small and dependency-free: the console + is an internal operator tool served under /_gm, not a public surface. */ +:root { + --bg: #11151c; + --panel: #1b2230; + --panel-hi: #232c3d; + --ink: #e6ebf2; + --ink-dim: #9aa7ba; + --line: #2c3850; + --accent: #5aa9ff; + --danger: #ff6b6b; + --ok: #4ecb8d; + --warn: #f1c453; +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +.topbar { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.6rem 1.2rem; + background: var(--panel); + border-bottom: 1px solid var(--line); +} +.topbar .brand { font-weight: 700; letter-spacing: 0.04em; } +.topbar .mainnav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; } +.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); } +.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; } +h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } +.lede { color: var(--ink-dim); margin-top: 0; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1.2rem 0; } +.card { + display: block; + padding: 1rem 1.2rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + color: var(--ink); +} +.card:hover { background: var(--panel-hi); text-decoration: none; } +.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); } +.card .bignum { font-size: 1.8rem; margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; } + +.panel { + padding: 0.9rem 1.1rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + margin-bottom: 1rem; +} +.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); } +.kv { list-style: none; margin: 0; padding: 0; } +.kv li { padding: 0.15rem 0; color: var(--ink-dim); } +.kv li b { color: var(--ink); font-weight: 600; } +.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; } +.ok { color: var(--ok); } +.bad { color: var(--danger); } +.warn { color: var(--warn); } + +.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; } +.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); } +.list th { color: var(--ink-dim); font-weight: 600; } +.list tr:hover td { background: var(--panel-hi); } +.list td.num { text-align: right; font-variant-numeric: tabular-nums; } +.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); } +.subnav { color: var(--ink-dim); margin: -0.2rem 0 1rem; font-size: 0.9rem; } +.subnav a.active { color: var(--ink); } + +.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; } +.form.col { flex-direction: column; align-items: stretch; max-width: 540px; } +.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); } +.form input, .form select, .form textarea { + background: var(--bg); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.35rem 0.5rem; + font: inherit; +} +.form textarea { min-height: 4rem; resize: vertical; } +button { + background: var(--accent); + color: #06121f; + border: 0; + border-radius: 6px; + padding: 0.4rem 0.9rem; + font: inherit; + font-weight: 600; + cursor: pointer; +} +button:hover { filter: brightness(1.1); } +button.danger { background: var(--danger); color: #1a0606; } +code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } +.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; } +.actions form { margin: 0; } +.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; } diff --git a/backend/internal/adminconsole/doc.go b/backend/internal/adminconsole/doc.go new file mode 100644 index 0000000..e1929b6 --- /dev/null +++ b/backend/internal/adminconsole/doc.go @@ -0,0 +1,9 @@ +// Package adminconsole renders the backend's server-side admin console: a small, +// dependency-free set of Go html/template pages plus one embedded stylesheet, +// served under /_gm. It owns the rendering and the page view models only; the gin +// handlers (internal/server) fetch the domain data, populate the view models and +// gate the surface — the gateway puts HTTP Basic-Auth in front of /_gm and a +// same-origin check guards the POST actions (docs/ARCHITECTURE.md §12). It mirrors +// the shape of galaxy-game's adminconsole package, minus the per-operator CSRF +// token and operator name (this console tracks no operator identity). +package adminconsole diff --git a/backend/internal/adminconsole/render.go b/backend/internal/adminconsole/render.go new file mode 100644 index 0000000..1246ffa --- /dev/null +++ b/backend/internal/adminconsole/render.go @@ -0,0 +1,101 @@ +package adminconsole + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "io" + "io/fs" + "path" + "strings" +) + +//go:embed templates +var templatesFS embed.FS + +//go:embed assets +var assetsFS embed.FS + +// Renderer holds the parsed admin console templates. It composes one template set +// per content page, each combining the shared layout (the page chrome and the +// "layout" entry template) with that page's "content" block, so rendering a page +// is a single ExecuteTemplate call against "layout". +type Renderer struct { + pages map[string]*template.Template +} + +// PageData is the view model passed to every admin console page. Title is the +// document title; ActiveNav marks the highlighted navigation entry; Data carries +// the page-specific payload (one of the *View types in views.go). +type PageData struct { + Title string + ActiveNav string + Data any +} + +// NewRenderer parses the embedded layout and every content page under +// templates/pages. It fails when a template cannot be parsed. +func NewRenderer() (*Renderer, error) { + base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml") + if err != nil { + return nil, fmt.Errorf("parse admin console layout: %w", err) + } + + pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml") + if err != nil { + return nil, fmt.Errorf("enumerate admin console pages: %w", err) + } + if len(pageFiles) == 0 { + return nil, fmt.Errorf("admin console: no page templates found under templates/pages") + } + + pages := make(map[string]*template.Template, len(pageFiles)) + for _, file := range pageFiles { + name := strings.TrimSuffix(path.Base(file), ".gohtml") + clone, err := base.Clone() + if err != nil { + return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err) + } + if _, err := clone.ParseFS(templatesFS, file); err != nil { + return nil, fmt.Errorf("parse admin console page %q: %w", name, err) + } + pages[name] = clone + } + + return &Renderer{pages: pages}, nil +} + +// MustNewRenderer is like NewRenderer but panics on error. The templates are +// embedded at build time, so a parse failure is a programmer error. +func MustNewRenderer() *Renderer { + renderer, err := NewRenderer() + if err != nil { + panic(err) + } + return renderer +} + +// Render writes the named page, wrapped in the shared layout, to w using data. It +// renders into an intermediate buffer first, so a mid-render failure never emits +// a partial document. It returns an error for an unknown page or a failed render. +func (r *Renderer) Render(w io.Writer, page string, data PageData) error { + tmpl, ok := r.pages[page] + if !ok { + return fmt.Errorf("admin console: unknown page %q", page) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil { + return fmt.Errorf("render admin console page %q: %w", page, err) + } + + _, err := buf.WriteTo(w) + return err +} + +// Assets returns the embedded static asset tree rooted at the assets directory, +// suitable for serving under /_gm/assets/. +func Assets() (fs.FS, error) { + return fs.Sub(assetsFS, "assets") +} diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go new file mode 100644 index 0000000..39778d2 --- /dev/null +++ b/backend/internal/adminconsole/render_test.go @@ -0,0 +1,69 @@ +package adminconsole + +import ( + "bytes" + "io/fs" + "strings" + "testing" +) + +// TestRendererRendersEveryPage parses the embedded templates and renders each +// page with a representative view, asserting the page executes, carries the +// shared layout chrome and shows a distinctive value. +func TestRendererRendersEveryPage(t *testing.T) { + r, err := NewRenderer() + if err != nil { + t.Fatalf("new renderer: %v", err) + } + cases := []struct { + page string + data any + want string + }{ + {"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"}, + {"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"}, + {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"}, + {"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"}, + {"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"}, + {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, + {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"}, + {"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"}, + {"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"}, + {"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"}, + } + for _, tc := range cases { + t.Run(tc.page, func(t *testing.T) { + var buf bytes.Buffer + if err := r.Render(&buf, tc.page, PageData{Title: tc.page, Data: tc.data}); err != nil { + t.Fatalf("render %s: %v", tc.page, err) + } + out := buf.String() + if !strings.Contains(out, tc.want) { + t.Errorf("render %s: missing %q in output", tc.page, tc.want) + } + if !strings.Contains(out, "Scrabble · admin") { + t.Errorf("render %s: missing layout chrome", tc.page) + } + }) + } +} + +// TestRendererUnknownPage reports an error for a page that does not exist. +func TestRendererUnknownPage(t *testing.T) { + r := MustNewRenderer() + if err := r.Render(&bytes.Buffer{}, "nope", PageData{}); err == nil { + t.Fatal("expected an error rendering an unknown page") + } +} + +// TestAssets confirms the stylesheet is embedded and reachable under the assets +// root. +func TestAssets(t *testing.T) { + fsys, err := Assets() + if err != nil { + t.Fatalf("assets: %v", err) + } + if _, err := fs.Stat(fsys, "console.css"); err != nil { + t.Errorf("console.css not embedded: %v", err) + } +} diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml new file mode 100644 index 0000000..64ee28e --- /dev/null +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -0,0 +1,28 @@ +{{define "layout" -}} + + + + + + +{{.Title}} · Scrabble admin + + + +
+ Scrabble · admin + +
+
+{{template "content" .}} +
+ + +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml new file mode 100644 index 0000000..f570231 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml @@ -0,0 +1,14 @@ +{{define "content" -}} +

Broadcast

+{{with .Data}} +

Post to the game channel

+{{if .ConnectorEnabled}} +
+ +
+
+{{else}}

connector not configured (set BACKEND_CONNECTOR_ADDR)

{{end}} +
+

To message a single user, open their user page.

+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/complaint_detail.gohtml b/backend/internal/adminconsole/templates/pages/complaint_detail.gohtml new file mode 100644 index 0000000..0ebbe57 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/complaint_detail.gohtml @@ -0,0 +1,32 @@ +{{define "content" -}} +{{with .Data}} +

Complaint: {{.Word}}

+ +

Details

+
    +
  • Word {{.Word}}
  • +
  • Variant {{.Variant}}
  • +
  • Dictionary {{.DictVersion}}
  • +
  • Lookup at filing {{if .WasValid}}valid{{else}}invalid{{end}}
  • +
  • Filer note {{if .Note}}{{.Note}}{{else}}none{{end}}
  • +
  • Game {{.GameID}}
  • +
  • Filed {{.CreatedAt}}
  • +
  • Status {{.Status}}
  • +{{if .Resolved}}
  • Disposition {{.Disposition}}
  • Resolution note {{.ResolutionNote}}
  • Resolved {{.ResolvedAt}}
  • {{end}} +
+
+

{{if .Resolved}}Re-resolve{{else}}Resolve{{end}}

+
+ + +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/complaints.gohtml b/backend/internal/adminconsole/templates/pages/complaints.gohtml new file mode 100644 index 0000000..a097469 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/complaints.gohtml @@ -0,0 +1,30 @@ +{{define "content" -}} +

Complaints

+{{with .Data}} + + + + +{{range .Items}} + + + + + + + + +{{else}}{{end}} + +
WordVariantWas validStatusDispositionFiled
{{.Word}}{{.Variant}}{{if .WasValid}}valid{{else}}invalid{{end}}{{.Status}}{{.Disposition}}{{.CreatedAt}}
no complaints
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/dashboard.gohtml b/backend/internal/adminconsole/templates/pages/dashboard.gohtml new file mode 100644 index 0000000..ed835a3 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/dashboard.gohtml @@ -0,0 +1,24 @@ +{{define "content" -}} +

Dashboard

+

Operator console for users, games, complaints and dictionaries.

+{{with .Data}} + +
+

Dictionaries

+ + + +{{range .Variants}} + +{{end}} + +
VariantLatestResident versions
{{.Variant}}{{.Latest}}{{range .Versions}}{{.}} {{end}}
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/dictionary.gohtml b/backend/internal/adminconsole/templates/pages/dictionary.gohtml new file mode 100644 index 0000000..aff7aa8 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/dictionary.gohtml @@ -0,0 +1,43 @@ +{{define "content" -}} +

Dictionary

+{{with .Data}} +

Resident versions

+ + + +{{range .Variants}} + +{{end}} + +
VariantLatestResident
{{.Variant}}{{.Latest}}{{range .Versions}}{{.}} {{end}}
+
+

Hot-reload a version

+

Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.

+
+ +
+
+
+

Pending dictionary changes

+ + + +{{range .Changes}} + +{{else}}{{end}} + +
VariantActionWordResolved
{{.Variant}}{{.Action}}{{.Word}}{{.ResolvedAt}}
no pending changes
+
+ + +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml new file mode 100644 index 0000000..313b31f --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -0,0 +1,29 @@ +{{define "content" -}} +{{with .Data}} +

Game {{.ID}}

+ +

Summary

+
    +
  • Variant {{.Variant}}
  • +
  • Dictionary {{.DictVersion}}
  • +
  • Status {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}
  • +
  • Players {{.Players}}
  • +
  • To move seat {{.ToMove}}
  • +
  • Moves {{.MoveCount}}
  • +
  • Created {{.CreatedAt}}
  • +
  • Updated {{.UpdatedAt}}
  • +{{if .FinishedAt}}
  • Finished {{.FinishedAt}}
  • {{end}} +
+
+

Seats

+ + + +{{range .Seats}} + +{{end}} + +
SeatPlayerScoreHintsWinner
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml new file mode 100644 index 0000000..2e21b88 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/games.gohtml @@ -0,0 +1,23 @@ +{{define "content" -}} +

Games

+{{with .Data}} + + + + +{{range .Items}} + +{{else}}{{end}} + +
GameVariantStatusPlayersUpdated
{{.ID}}{{.Variant}}{{.Status}}{{.Players}}{{.UpdatedAt}}
no games
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/message.gohtml b/backend/internal/adminconsole/templates/pages/message.gohtml new file mode 100644 index 0000000..34b69a6 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/message.gohtml @@ -0,0 +1,7 @@ +{{define "content" -}} +{{with .Data}} +

{{.Heading}}

+

{{.Body}}

+

« back

+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml new file mode 100644 index 0000000..824ca10 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -0,0 +1,60 @@ +{{define "content" -}} +{{with .Data}} +

{{.DisplayName}}

+ +
+

Account

+
    +
  • ID {{.ID}}
  • +
  • Language {{.Language}}
  • +
  • Timezone {{.TimeZone}}
  • +
  • Guest {{if .Guest}}yes{{else}}no{{end}}
  • +
  • Push {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}
  • +
  • Hint wallet {{.HintBalance}}
  • +
  • Created {{.CreatedAt}}
  • +
+
+

Statistics

+{{if .HasStats}} +
    +
  • Wins {{.Stats.Wins}}
  • +
  • Losses {{.Stats.Losses}}
  • +
  • Draws {{.Stats.Draws}}
  • +
  • Best game {{.Stats.MaxGamePoints}}
  • +
  • Best move {{.Stats.MaxWordPoints}}
  • +
+{{else}}

no statistics

{{end}} +
+
+

Identities

+ + + +{{range .Identities}} + +{{else}}{{end}} + +
KindExternal IDConfirmedCreated
{{.Kind}}{{.ExternalID}}{{if .Confirmed}}yes{{else}}no{{end}}{{.CreatedAt}}
no identities (guest)
+
+{{if .TelegramID}} +

Send Telegram message

+{{if .ConnectorEnabled}} +
+ +
+
+{{else}}

connector not configured (set BACKEND_CONNECTOR_ADDR)

{{end}} +
+{{end}} +

Games

+ + + +{{range .Games}} + +{{else}}{{end}} + +
GameVariantStatusPlayersUpdated
{{.ID}}{{.Variant}}{{.Status}}{{.Players}}{{.UpdatedAt}}
no games
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml new file mode 100644 index 0000000..4ed3b6d --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -0,0 +1,26 @@ +{{define "content" -}} +

Users

+{{with .Data}} + + + +{{range .Items}} + + + + + + + +{{else}} + +{{end}} + +
AccountDisplay nameKindLangCreated
{{.ID}}{{.DisplayName}}{{if .Guest}} guest{{end}}{{.Kind}}{{.Language}}{{.CreatedAt}}
no users
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go new file mode 100644 index 0000000..7384ac1 --- /dev/null +++ b/backend/internal/adminconsole/views.go @@ -0,0 +1,200 @@ +package adminconsole + +// The *View types are the display models the gin handlers fill and the templates +// render. Time values are pre-formatted to strings by the handlers so the +// templates stay logic-free. + +// Pager is the shared list pagination state. +type Pager struct { + Page int + PageSize int + Total int + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +// NewPager builds the pagination state for a 1-based page of pageSize over total +// items. +func NewPager(page, pageSize, total int) Pager { + if page < 1 { + page = 1 + } + p := Pager{Page: page, PageSize: pageSize, Total: total, PrevPage: page - 1, NextPage: page + 1} + p.HasPrev = page > 1 + p.HasNext = page*pageSize < total + return p +} + +// VariantVersions lists the dictionary versions resident for one variant. +type VariantVersions struct { + Variant string + Latest string + Versions []string +} + +// DashboardView is the landing-page summary. +type DashboardView struct { + Accounts int + Games int + ActiveGames int + OpenComplaints int + PendingChanges int + Variants []VariantVersions +} + +// UsersView is the paginated account list. +type UsersView struct { + Items []UserRow + Pager Pager +} + +// UserRow is one account row in the list. +type UserRow struct { + ID string + DisplayName string + Kind string + Language string + Guest bool + CreatedAt string +} + +// UserDetailView is one account with its stats, identities and recent games. +type UserDetailView struct { + ID string + DisplayName string + Language string + TimeZone string + Guest bool + NotificationsInAppOnly bool + HintBalance int + CreatedAt string + HasStats bool + Stats StatsRow + Identities []IdentityRow + Games []GameRow + TelegramID string + ConnectorEnabled bool +} + +// StatsRow is an account's lifetime statistics. +type StatsRow struct { + Wins int + Losses int + Draws int + MaxGamePoints int + MaxWordPoints int +} + +// IdentityRow is one platform/email identity of an account. +type IdentityRow struct { + Kind string + ExternalID string + Confirmed bool + CreatedAt string +} + +// GameRow is one game row in a list. +type GameRow struct { + ID string + Variant string + Status string + Players int + UpdatedAt string +} + +// GamesView is the paginated games list, optionally filtered by status. +type GamesView struct { + Items []GameRow + Status string + Pager Pager +} + +// GameDetailView is one game with its seats. +type GameDetailView struct { + ID string + Variant string + DictVersion string + Status string + Players int + ToMove int + EndReason string + MoveCount int + CreatedAt string + UpdatedAt string + FinishedAt string + Seats []SeatRow +} + +// SeatRow is one seat of a game. +type SeatRow struct { + Seat int + DisplayName string + AccountID string + Score int + HintsUsed int + Winner bool +} + +// ComplaintsView is the paginated complaint review queue. +type ComplaintsView struct { + Items []ComplaintRow + Status string + Pager Pager +} + +// ComplaintRow is one complaint row in the queue. +type ComplaintRow struct { + ID string + Word string + Variant string + WasValid bool + Status string + Disposition string + CreatedAt string +} + +// ComplaintDetailView is one complaint with its resolution state and form. +type ComplaintDetailView struct { + ID string + Word string + Variant string + DictVersion string + WasValid bool + Note string + Status string + Disposition string + ResolutionNote string + CreatedAt string + ResolvedAt string + GameID string + Resolved bool +} + +// DictionaryView lists the resident versions per variant and the pending +// wordlist changes from accepted complaints. +type DictionaryView struct { + Variants []VariantVersions + Changes []DictChangeRow +} + +// DictChangeRow is one pending wordlist edit. +type DictChangeRow struct { + Variant string + Word string + Action string + ResolvedAt string +} + +// BroadcastView is the operator-broadcast form page. +type BroadcastView struct { + ConnectorEnabled bool +} + +// MessageView is the result page shown after a POST action. +type MessageView struct { + Heading string + Body string + Back string +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 739410a..a6726ab 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -38,6 +38,10 @@ type Config struct { // SMTP configures the email relay used for confirm-codes. An empty Host // selects the development log mailer (the code is logged, not sent). SMTP account.SMTPConfig + // ConnectorAddr is the gRPC address of the Telegram platform connector + // side-service, used by the admin console to send operator broadcasts. Empty + // disables broadcasts (the admin broadcast actions report "not configured"). + ConnectorAddr string } // Defaults applied when the corresponding environment variable is unset. @@ -103,15 +107,16 @@ func Load() (Config, error) { } c := Config{ - HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), - GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), - LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), - Postgres: pg, - Telemetry: tel, - Game: gm, - Lobby: lb, - Robot: rb, - SMTP: smtp, + HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), + GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), + LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), + Postgres: pg, + Telemetry: tel, + Game: gm, + Lobby: lb, + Robot: rb, + SMTP: smtp, + ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"), } if err := c.validate(); err != nil { return Config{}, err diff --git a/backend/internal/connector/client.go b/backend/internal/connector/client.go new file mode 100644 index 0000000..f34f5fb --- /dev/null +++ b/backend/internal/connector/client.go @@ -0,0 +1,58 @@ +// Package connector is the backend's gRPC client for the Telegram platform +// connector side-service. The admin console uses it to send operator broadcasts: +// a direct message to one user, or a post to the connector's configured game +// channel. The connector lives on the trusted internal network, so the +// connection uses insecure (plaintext) transport credentials +// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to +// the two broadcast methods the admin surface needs. +package connector + +import ( + "context" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + telegramv1 "scrabble/pkg/proto/telegram/v1" +) + +// Client wraps the connector's Telegram gRPC service. +type Client struct { + conn *grpc.ClientConn + c telegramv1.TelegramClient +} + +// New dials the connector gRPC endpoint at addr. +func New(addr string) (*Client, error) { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("connector: dial %s: %w", addr, err) + } + return &Client{conn: conn, c: telegramv1.NewTelegramClient(conn)}, nil +} + +// Close releases the gRPC connection. +func (c *Client) Close() error { return c.conn.Close() } + +// SendToUser sends an operator text message to one user, addressed by their +// platform external_id. delivered reports whether the connector actually sent it +// (false when the user has not started the bot). +func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) { + resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text}) + if err != nil { + return false, err + } + return resp.GetDelivered(), nil +} + +// SendToGameChannel posts an operator text message to the connector's configured +// game channel. delivered reports whether the connector sent it (false when no +// channel is configured). +func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) { + resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text}) + if err != nil { + return false, err + } + return resp.GetDelivered(), nil +} diff --git a/backend/internal/engine/registry.go b/backend/internal/engine/registry.go index c195d4f..386e2bc 100644 --- a/backend/internal/engine/registry.go +++ b/backend/internal/engine/registry.go @@ -2,6 +2,7 @@ package engine import ( "fmt" + "os" "path/filepath" "sort" "sync" @@ -63,6 +64,36 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) { return r, nil } +// OpenWithVersions builds a Registry by loading the boot version from the flat +// dir (every variant, as Open) and then every additional version held in an +// immediate subdirectory of dir: a subdirectory named V contributes, under +// version V, the variants whose committed DAWG it carries. This is the +// restart-side of the admin dictionary reload — a version reloaded into dir// +// at runtime is resident again after a restart. A subdirectory named like the +// boot version is skipped (the flat dir already is the boot version). A partially +// loaded registry is closed before any error is returned. +func OpenWithVersions(dir, bootVersion string) (*Registry, error) { + r, err := Open(dir, bootVersion) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + _ = r.Close() + return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err) + } + for _, e := range entries { + if !e.IsDir() || e.Name() == bootVersion { + continue + } + if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil { + _ = r.Close() + return nil, err + } + } + return r, nil +} + // Load reads the committed DAWG of variant from dir, builds a solver over it and // registers it under version. Reloading the same (variant, version) replaces the // previous entry, closing its finder. The most recently loaded version of a @@ -91,6 +122,29 @@ func (r *Registry) Load(v Variant, version, dir string) error { return nil } +// LoadAvailable loads, under version, every variant whose committed DAWG is +// present in dir, skipping a variant whose file is absent. It backs the admin +// dictionary reload (a version subdirectory may carry only the variants that were +// rebuilt) and OpenWithVersions' boot-time scan. It returns the variants it +// loaded, in catalogue order, or the first load error. +func (r *Registry) LoadAvailable(dir, version string) ([]Variant, error) { + var loaded []Variant + for _, v := range Variants() { + path := filepath.Join(dir, dictFiles[v]) + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + continue + } + return loaded, fmt.Errorf("engine: stat %s dictionary %q in %s: %w", v, version, dir, err) + } + if err := r.Load(v, version, dir); err != nil { + return loaded, err + } + loaded = append(loaded, v) + } + return loaded, nil +} + // Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant // when the variant is absent and ErrUnknownVersion when only the version is. func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) { diff --git a/backend/internal/engine/reload_test.go b/backend/internal/engine/reload_test.go new file mode 100644 index 0000000..bf55772 --- /dev/null +++ b/backend/internal/engine/reload_test.go @@ -0,0 +1,119 @@ +package engine + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +// copyDawg copies the committed DAWG for v from srcDir into dstDir (creating +// dstDir). It is the fixture builder for the dictionary-reload tests, which need +// real DAWG files laid out in temporary version directories. +func copyDawg(t *testing.T, srcDir, dstDir string, v Variant) { + t.Helper() + if err := os.MkdirAll(dstDir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dstDir, err) + } + name := dictFiles[v] + src, err := os.Open(filepath.Join(srcDir, name)) + if err != nil { + t.Fatalf("open source dawg %s: %v", name, err) + } + defer func() { _ = src.Close() }() + dst, err := os.Create(filepath.Join(dstDir, name)) + if err != nil { + t.Fatalf("create dest dawg %s: %v", name, err) + } + defer func() { _ = dst.Close() }() + if _, err := io.Copy(dst, src); err != nil { + t.Fatalf("copy dawg %s: %v", name, err) + } +} + +// TestLoadAvailableLoadsPresentSkipsAbsent verifies LoadAvailable registers only +// the variants whose DAWG is present in the directory, under the given version. +func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) { + dir := t.TempDir() + copyDawg(t, testDictDir(), dir, VariantEnglish) // only English present + + reg := NewRegistry() + defer func() { _ = reg.Close() }() + loaded, err := reg.LoadAvailable(dir, "v2") + if err != nil { + t.Fatalf("load available: %v", err) + } + if len(loaded) != 1 || loaded[0] != VariantEnglish { + t.Fatalf("loaded = %v, want [english]", loaded) + } + if _, err := reg.Solver(VariantEnglish, "v2"); err != nil { + t.Errorf("english v2 solver: %v", err) + } + if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) { + t.Errorf("russian v2 should be absent: got %v", err) + } +} + +// TestOpenWithVersionsScansSubdirs verifies the boot helper loads the flat boot +// version plus every version subdirectory, the subdir version becoming the +// variant's latest while the boot version stays resident. +func TestOpenWithVersionsScansSubdirs(t *testing.T) { + dir := t.TempDir() + for _, v := range Variants() { // flat boot version: all three variants + copyDawg(t, testDictDir(), dir, v) + } + copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // v2 subdir: English only + + reg, err := OpenWithVersions(dir, "v1") + if err != nil { + t.Fatalf("open with versions: %v", err) + } + defer func() { _ = reg.Close() }() + + for _, v := range Variants() { + if _, err := reg.Solver(v, "v1"); err != nil { + t.Errorf("boot solver %s/v1: %v", v, err) + } + } + if got := reg.Versions(VariantEnglish); len(got) != 2 { + t.Errorf("english versions = %v, want two", got) + } + latest, _, err := reg.Latest(VariantEnglish) + if err != nil { + t.Fatalf("latest english: %v", err) + } + if latest != "v2" { + t.Errorf("latest english = %q, want v2", latest) + } + if got := reg.Versions(VariantRussianScrabble); len(got) != 1 { + t.Errorf("russian versions = %v, want one (no v2 file)", got) + } +} + +// TestReloadRegistersNewVersion verifies Load adds a second version to a variant +// already resident, moves the latest pointer and keeps the earlier version. +func TestReloadRegistersNewVersion(t *testing.T) { + reg, err := Open(testDictDir(), "v1", VariantEnglish) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = reg.Close() }() + + if err := reg.Load(VariantEnglish, "v2", testDictDir()); err != nil { + t.Fatalf("reload v2: %v", err) + } + if got := reg.Versions(VariantEnglish); len(got) != 2 { + t.Fatalf("versions = %v, want two", got) + } + latest, _, err := reg.Latest(VariantEnglish) + if err != nil { + t.Fatalf("latest: %v", err) + } + if latest != "v2" { + t.Errorf("latest = %q, want v2", latest) + } + if _, err := reg.Solver(VariantEnglish, "v1"); err != nil { + t.Errorf("v1 still resident: %v", err) + } +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 33ae347..d7e85c9 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -420,6 +420,68 @@ func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UU }) } +// ListComplaints returns word-check complaints for the admin review queue, +// newest first. status filters by lifecycle state ("" = all); limit is clamped +// to a sane page size and offset is floored at zero. +func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) { + return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset)) +} + +// GetComplaint loads a single complaint for the admin detail view. +func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) { + return svc.store.GetComplaint(ctx, id) +} + +// CountComplaints returns the number of complaints, optionally restricted to a +// status, for the admin queue pager and the dashboard counts. +func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) { + return svc.store.CountComplaints(ctx, status) +} + +// ResolveComplaint closes a complaint with an operator disposition (reject / +// accept_add / accept_remove) and an optional note. An accepted complaint then +// appears in DictionaryChanges until a rebuilt dictionary is loaded and the +// change is marked applied. It returns ErrInvalidConfig for an unknown +// disposition and ErrNotFound when no complaint matches. +func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) { + if !validDisposition(disposition) { + return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition) + } + return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock()) +} + +// DictionaryChanges returns the pending wordlist edits implied by resolved, +// accepted complaints not yet marked applied — the input to the offline DAWG +// rebuild. +func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) { + rows, err := svc.store.ListDictionaryChanges(ctx) + if err != nil { + return nil, err + } + out := make([]DictionaryChange, 0, len(rows)) + for _, c := range rows { + ch := DictionaryChange{ + ComplaintID: c.ID, + Variant: c.Variant, + Word: c.Word, + Add: c.Disposition == DispositionAcceptAdd, + Note: c.Note, + } + if c.ResolvedAt != nil { + ch.ResolvedAt = *c.ResolvedAt + } + out = append(out, ch) + } + return out, nil +} + +// MarkChangesApplied records that every pending accepted change for variant has +// been folded into the dictionary version that was just hot-reloaded, removing +// them from DictionaryChanges. It returns the number of changes marked. +func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) { + return svc.store.MarkChangesApplied(ctx, variant.String(), version) +} + // Hint reveals the top-scoring legal play for the requesting player on their // turn, spending one hint from their per-game allowance and then their profile // wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as @@ -583,6 +645,23 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([] return svc.store.ListGamesForAccount(ctx, accountID) } +// GameByID returns a game with its seats for the admin console detail view. +func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) { + return svc.store.GetGame(ctx, id) +} + +// ListGames returns games for the admin list, newest-updated first, paginated, +// optionally filtered by status. +func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) { + return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset)) +} + +// CountGames returns the game count, optionally filtered by status, for the admin +// list pager and dashboard. +func (svc *Service) CountGames(ctx context.Context, status string) (int, error) { + return svc.store.CountGames(ctx, status) +} + // History returns a game's full, dictionary-independent move journal. func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) { g, err := svc.store.GetGame(ctx, gameID) @@ -770,6 +849,29 @@ func normalizeWord(word string) string { return strings.ToLower(strings.TrimSpace(word)) } +// validDisposition reports whether d is an accepted complaint disposition. +func validDisposition(d string) bool { + switch d { + case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove: + return true + default: + return false + } +} + +// clampPageSize bounds an admin list page size to [1, 200], defaulting an unset +// (non-positive) request to 50. +func clampPageSize(limit int) int { + switch { + case limit <= 0: + return 50 + case limit > 200: + return 200 + default: + return limit + } +} + // randomSeed returns an unpredictable bag seed, falling back to the clock if the // system source fails. func randomSeed() int64 { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 0e4875f..16b5bdf 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -197,6 +197,53 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([ return out, nil } +// ListGames returns games for the admin games list, most-recently-updated first, +// paginated. status filters by lifecycle ("active"/"finished") when non-empty. +// The seats are not loaded — the list shows summaries; the detail view uses +// GetGame. +func (s *Store) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) { + where := postgres.Bool(true) + if status != "" { + where = table.Games.Status.EQ(postgres.String(status)) + } + stmt := postgres.SELECT(table.Games.AllColumns). + FROM(table.Games). + WHERE(where). + ORDER_BY(table.Games.UpdatedAt.DESC()). + LIMIT(int64(limit)). + OFFSET(int64(offset)) + var rows []model.Games + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("game: list games: %w", err) + } + out := make([]Game, 0, len(rows)) + for _, g := range rows { + pg, err := projectGame(g, nil) + if err != nil { + return nil, err + } + out = append(out, pg) + } + return out, nil +} + +// CountGames returns the number of games, optionally restricted to a status, for +// admin-list pagination. +func (s *Store) CountGames(ctx context.Context, status string) (int, error) { + where := postgres.Bool(true) + if status != "" { + where = table.Games.Status.EQ(postgres.String(status)) + } + stmt := postgres.SELECT(postgres.COUNT(table.Games.GameID).AS("count")). + FROM(table.Games). + WHERE(where) + var dest struct{ Count int64 } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return 0, fmt.Errorf("game: count games: %w", err) + } + return int(dest.Count), nil +} + // GetJournal loads the ordered, decoded move journal for a game. func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) { stmt := postgres.SELECT(table.GameMoves.AllColumns). @@ -384,6 +431,122 @@ func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, erro return projectComplaint(row) } +// ListComplaints returns complaints for the admin review queue, newest first. +// status filters by lifecycle state when non-empty; limit and offset paginate. +func (s *Store) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) { + where := postgres.Bool(true) + if status != "" { + where = table.Complaints.Status.EQ(postgres.String(status)) + } + stmt := postgres.SELECT(table.Complaints.AllColumns). + FROM(table.Complaints). + WHERE(where). + ORDER_BY(table.Complaints.CreatedAt.DESC()). + LIMIT(int64(limit)). + OFFSET(int64(offset)) + var rows []model.Complaints + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("game: list complaints: %w", err) + } + return projectComplaints(rows) +} + +// GetComplaint loads one complaint by id, or ErrNotFound. +func (s *Store) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) { + stmt := postgres.SELECT(table.Complaints.AllColumns). + FROM(table.Complaints). + WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.Complaints + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Complaint{}, ErrNotFound + } + return Complaint{}, fmt.Errorf("game: get complaint %s: %w", id, err) + } + return projectComplaint(row) +} + +// ResolveComplaint closes a complaint with a disposition and note, stamping +// resolved_at, and returns the updated row (ErrNotFound when none matches). It +// leaves applied_in_version untouched. +func (s *Store) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string, now time.Time) (Complaint, error) { + stmt := table.Complaints.UPDATE( + table.Complaints.Status, table.Complaints.Disposition, + table.Complaints.ResolutionNote, table.Complaints.ResolvedAt, + ).SET( + postgres.String(StatusComplaintResolved), postgres.String(disposition), + postgres.String(note), postgres.TimestampzT(now), + ).WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))). + RETURNING(table.Complaints.AllColumns) + var row model.Complaints + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Complaint{}, ErrNotFound + } + return Complaint{}, fmt.Errorf("game: resolve complaint %s: %w", id, err) + } + return projectComplaint(row) +} + +// ListDictionaryChanges returns the resolved, accepted complaints not yet marked +// applied (the pending wordlist edits), ordered by variant then resolution time. +func (s *Store) ListDictionaryChanges(ctx context.Context) ([]Complaint, error) { + stmt := postgres.SELECT(table.Complaints.AllColumns). + FROM(table.Complaints). + WHERE( + table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)). + AND(table.Complaints.Disposition.IN( + postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove), + )). + AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))), + ). + ORDER_BY(table.Complaints.Variant.ASC(), table.Complaints.ResolvedAt.ASC()) + var rows []model.Complaints + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("game: list dictionary changes: %w", err) + } + return projectComplaints(rows) +} + +// MarkChangesApplied stamps every pending accepted change for variant with +// version (so it drops out of ListDictionaryChanges) and returns the count. +func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string) (int64, error) { + stmt := table.Complaints.UPDATE(table.Complaints.AppliedInVersion). + SET(postgres.String(version)). + WHERE( + table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)). + AND(table.Complaints.Variant.EQ(postgres.String(variant))). + AND(table.Complaints.Disposition.IN( + postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove), + )). + AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))), + ) + res, err := stmt.ExecContext(ctx, s.db) + if err != nil { + return 0, fmt.Errorf("game: mark changes applied: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} + +// CountComplaints returns the number of complaints, optionally restricted to a +// status, for the admin queue pager and the dashboard counts. +func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) { + where := postgres.Bool(true) + if status != "" { + where = table.Complaints.Status.EQ(postgres.String(status)) + } + stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")). + FROM(table.Complaints). + WHERE(where) + var dest struct{ Count int64 } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return 0, fmt.Errorf("game: count complaints: %w", err) + } + return int(dest.Count), nil +} + // ActiveGames returns the turn clocks of every in-progress game; the sweeper // filters them against the per-move deadline and the player's away window. func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) { @@ -523,19 +686,36 @@ func projectComplaint(row model.Complaints) (Complaint, error) { return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err) } return Complaint{ - ID: row.ComplaintID, - ComplainantID: row.ComplainantID, - GameID: row.GameID, - Variant: variant, - DictVersion: row.DictVersion, - Word: row.Word, - WasValid: row.WasValid, - Note: row.Note, - Status: row.Status, - CreatedAt: row.CreatedAt, + ID: row.ComplaintID, + ComplainantID: row.ComplainantID, + GameID: row.GameID, + Variant: variant, + DictVersion: row.DictVersion, + Word: row.Word, + WasValid: row.WasValid, + Note: row.Note, + Status: row.Status, + CreatedAt: row.CreatedAt, + Disposition: row.Disposition, + ResolutionNote: row.ResolutionNote, + ResolvedAt: row.ResolvedAt, + AppliedInVersion: row.AppliedInVersion, }, nil } +// projectComplaints projects a slice of complaint rows, preserving order. +func projectComplaints(rows []model.Complaints) ([]Complaint, error) { + out := make([]Complaint, 0, len(rows)) + for _, r := range rows { + c, err := projectComplaint(r) + if err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + // withTx wraps fn in a transaction, committing on nil 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) diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 6f46541..fc2e031 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -15,9 +15,23 @@ const ( StatusFinished = "finished" ) -// ComplaintStatus values; Stage 10 owns the resolution lifecycle, Stage 3 only -// ever writes StatusComplaintOpen. -const StatusComplaintOpen = "open" +// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3) +// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a +// Disposition. The CHECK constraints live in migration 00008. +const ( + StatusComplaintOpen = "open" + StatusComplaintResolved = "resolved" +) + +// Complaint dispositions chosen at resolution. DispositionReject keeps the +// dictionary as-is; DispositionAcceptAdd / DispositionAcceptRemove mark the word +// for addition to / removal from the variant's wordlist and feed the offline +// dictionary-rebuild pipeline (see DictionaryChange). +const ( + DispositionReject = "reject" + DispositionAcceptAdd = "accept_add" + DispositionAcceptRemove = "accept_remove" +) // Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay, // engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the @@ -179,7 +193,9 @@ type RobotTurn struct { Seed int64 } -// Complaint is a word-check complaint awaiting admin review (Stage 10). +// Complaint is a word-check complaint in the admin review queue. It is filed +// against a game's pinned (Variant, DictVersion) with the lookup result at filing +// time (WasValid); the resolution fields stay empty until an operator resolves it. type Complaint struct { ID uuid.UUID ComplainantID uuid.UUID @@ -191,4 +207,24 @@ type Complaint struct { Note string Status string CreatedAt time.Time + + // Resolution fields, set when Status == StatusComplaintResolved. + Disposition string // "" while open; otherwise a Disposition* value + ResolutionNote string // operator note recorded at resolution + ResolvedAt *time.Time // nil while open + AppliedInVersion string // dict version an accepted change was folded into ("" = pending) +} + +// DictionaryChange is the wordlist edit implied by one resolved, accepted +// complaint: Add reports whether Word should be added (DispositionAcceptAdd) or +// removed (DispositionAcceptRemove) for Variant. The admin console lists the +// pending changes as the input to the offline DAWG rebuild; once a rebuilt +// dictionary version is hot-reloaded they are marked applied. +type DictionaryChange struct { + ComplaintID uuid.UUID + Variant engine.Variant + Word string + Add bool + ResolvedAt time.Time + Note string } diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go new file mode 100644 index 0000000..3059643 --- /dev/null +++ b/backend/internal/inttest/admin_test.go @@ -0,0 +1,183 @@ +//go:build integration + +package inttest + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/server" +) + +// TestComplaintResolutionPipeline drives a complaint from filing through +// resolution into the dictionary-change pipeline and on to "applied". +func TestComplaintResolutionPipeline(t *testing.T) { + ctx := context.Background() + svc := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1}) + if err != nil { + t.Fatalf("create: %v", err) + } + word := "zzzzzz" // a non-word the filer thinks should be valid → an accept_add candidate + filed, err := svc.FileComplaint(ctx, g.ID, seats[0], word, "please add") + if err != nil { + t.Fatalf("file: %v", err) + } + + if open, _ := svc.CountComplaints(ctx, game.StatusComplaintOpen); open < 1 { + t.Fatalf("open complaints = %d, want >= 1", open) + } + list, err := svc.ListComplaints(ctx, game.StatusComplaintOpen, 100, 0) + if err != nil || !containsComplaint(list, filed.ID) { + t.Fatalf("open list missing filed complaint (err %v)", err) + } + + resolved, err := svc.ResolveComplaint(ctx, filed.ID, game.DispositionAcceptAdd, "agreed") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if resolved.Status != game.StatusComplaintResolved || resolved.Disposition != game.DispositionAcceptAdd || resolved.ResolvedAt == nil { + t.Fatalf("unexpected resolved complaint: %+v", resolved) + } + + changes, err := svc.DictionaryChanges(ctx) + if err != nil { + t.Fatalf("changes: %v", err) + } + if !changeFor(changes, word, true) { + t.Fatalf("dictionary changes missing add %q: %+v", word, changes) + } + + n, err := svc.MarkChangesApplied(ctx, engine.VariantEnglish, "v2") + if err != nil || n < 1 { + t.Fatalf("mark applied n=%d err=%v", n, err) + } + if after, err := svc.DictionaryChanges(ctx); err != nil || changeFor(after, word, true) { + t.Fatalf("change still pending after apply (err %v): %+v", err, after) + } +} + +func containsComplaint(list []game.Complaint, id uuid.UUID) bool { + for _, c := range list { + if c.ID == id { + return true + } + } + return false +} + +func changeFor(changes []game.DictionaryChange, word string, add bool) bool { + for _, c := range changes { + if c.Word == word && c.Add == add { + return true + } + } + return false +} + +// TestAdminListsAndCounts checks the admin read queries and their COUNT scans. +func TestAdminListsAndCounts(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + svc := newGameService() + + accBefore, err := store.CountAccounts(ctx) + if err != nil { + t.Fatalf("count accounts: %v", err) + } + a, b := provisionAccount(t), provisionAccount(t) + accAfter, err := store.CountAccounts(ctx) + if err != nil { + t.Fatalf("count accounts: %v", err) + } + if accAfter < accBefore+2 { + t.Errorf("account count did not grow by 2: %d -> %d", accBefore, accAfter) + } + if page, err := store.ListAccounts(ctx, 1, 0); err != nil || len(page) != 1 { + t.Fatalf("list accounts page size 1 = %d (err %v)", len(page), err) + } + if ids, err := store.Identities(ctx, a); err != nil || len(ids) != 1 || ids[0].Kind != account.KindTelegram { + t.Fatalf("identities for a = %+v (err %v)", ids, err) + } + + gBefore, _ := svc.CountGames(ctx, "") + if _, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 2}); err != nil { + t.Fatalf("create: %v", err) + } + if gAfter, _ := svc.CountGames(ctx, ""); gAfter != gBefore+1 { + t.Errorf("game count %d -> %d, want +1", gBefore, gAfter) + } + if active, err := svc.ListGames(ctx, game.StatusActive, 100, 0); err != nil || len(active) == 0 { + t.Fatalf("list active games = %d (err %v)", len(active), err) + } +} + +// TestConsoleServesAndGuardsCSRF drives the /_gm console over HTTP against real +// stores: pages render, and a state-changing POST needs a same-origin header. +func TestConsoleServesAndGuardsCSRF(t *testing.T) { + ctx := context.Background() + svc := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 3}) + if err != nil { + t.Fatalf("create: %v", err) + } + filed, err := svc.FileComplaint(ctx, g.ID, seats[0], "qwxz", "review") + if err != nil { + t.Fatalf("file: %v", err) + } + + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), + Accounts: account.NewStore(testDB), + Games: svc, + Registry: testRegistry, + DictDir: dictDir(), + }) + h := srv.Handler() + base := "http://admin.test/_gm" + + if code, body := consoleDo(h, http.MethodGet, base+"/", "", ""); code != http.StatusOK || !strings.Contains(body, "Dashboard") { + t.Fatalf("dashboard = %d, has Dashboard=%v", code, strings.Contains(body, "Dashboard")) + } + if code, body := consoleDo(h, http.MethodGet, base+"/complaints/"+filed.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "qwxz") { + t.Fatalf("complaint detail = %d, has word=%v", code, strings.Contains(body, "qwxz")) + } + // A resolve POST without a same-origin header is rejected by the CSRF guard. + if code, _ := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject", ""); code != http.StatusForbidden { + t.Fatalf("resolve without origin = %d, want 403", code) + } + // With a matching Origin it succeeds and persists. + if code, body := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject¬e=ok", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Resolved") { + t.Fatalf("resolve with origin = %d, has Resolved=%v", code, strings.Contains(body, "Resolved")) + } + if got, err := svc.GetComplaint(ctx, filed.ID); err != nil || got.Status != game.StatusComplaintResolved { + t.Fatalf("complaint not resolved: %+v (err %v)", got, err) + } +} + +// consoleDo issues a request to h, optionally with an Origin header, and returns +// the status and body. Form bodies are sent as application/x-www-form-urlencoded. +func consoleDo(h http.Handler, method, target, body, origin string) (int, string) { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if origin != "" { + req.Header.Set("Origin", origin) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code, rec.Body.String() +} diff --git a/backend/internal/postgres/jet/backend/model/complaints.go b/backend/internal/postgres/jet/backend/model/complaints.go index 53c5e56..90f2bda 100644 --- a/backend/internal/postgres/jet/backend/model/complaints.go +++ b/backend/internal/postgres/jet/backend/model/complaints.go @@ -13,14 +13,18 @@ import ( ) type Complaints struct { - ComplaintID uuid.UUID `sql:"primary_key"` - ComplainantID uuid.UUID - GameID uuid.UUID - Variant string - DictVersion string - Word string - WasValid bool - Note string - Status string - CreatedAt time.Time + ComplaintID uuid.UUID `sql:"primary_key"` + ComplainantID uuid.UUID + GameID uuid.UUID + Variant string + DictVersion string + Word string + WasValid bool + Note string + Status string + CreatedAt time.Time + Disposition string + ResolutionNote string + ResolvedAt *time.Time + AppliedInVersion string } diff --git a/backend/internal/postgres/jet/backend/table/complaints.go b/backend/internal/postgres/jet/backend/table/complaints.go index 4ac0588..8e59be3 100644 --- a/backend/internal/postgres/jet/backend/table/complaints.go +++ b/backend/internal/postgres/jet/backend/table/complaints.go @@ -17,16 +17,20 @@ type complaintsTable struct { postgres.Table // Columns - ComplaintID postgres.ColumnString - ComplainantID postgres.ColumnString - GameID postgres.ColumnString - Variant postgres.ColumnString - DictVersion postgres.ColumnString - Word postgres.ColumnString - WasValid postgres.ColumnBool - Note postgres.ColumnString - Status postgres.ColumnString - CreatedAt postgres.ColumnTimestampz + ComplaintID postgres.ColumnString + ComplainantID postgres.ColumnString + GameID postgres.ColumnString + Variant postgres.ColumnString + DictVersion postgres.ColumnString + Word postgres.ColumnString + WasValid postgres.ColumnBool + Note postgres.ColumnString + Status postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + Disposition postgres.ColumnString + ResolutionNote postgres.ColumnString + ResolvedAt postgres.ColumnTimestampz + AppliedInVersion postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -68,35 +72,43 @@ func newComplaintsTable(schemaName, tableName, alias string) *ComplaintsTable { func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable { var ( - ComplaintIDColumn = postgres.StringColumn("complaint_id") - ComplainantIDColumn = postgres.StringColumn("complainant_id") - GameIDColumn = postgres.StringColumn("game_id") - VariantColumn = postgres.StringColumn("variant") - DictVersionColumn = postgres.StringColumn("dict_version") - WordColumn = postgres.StringColumn("word") - WasValidColumn = postgres.BoolColumn("was_valid") - NoteColumn = postgres.StringColumn("note") - StatusColumn = postgres.StringColumn("status") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn} - mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn} - defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn} + ComplaintIDColumn = postgres.StringColumn("complaint_id") + ComplainantIDColumn = postgres.StringColumn("complainant_id") + GameIDColumn = postgres.StringColumn("game_id") + VariantColumn = postgres.StringColumn("variant") + DictVersionColumn = postgres.StringColumn("dict_version") + WordColumn = postgres.StringColumn("word") + WasValidColumn = postgres.BoolColumn("was_valid") + NoteColumn = postgres.StringColumn("note") + StatusColumn = postgres.StringColumn("status") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + DispositionColumn = postgres.StringColumn("disposition") + ResolutionNoteColumn = postgres.StringColumn("resolution_note") + ResolvedAtColumn = postgres.TimestampzColumn("resolved_at") + AppliedInVersionColumn = postgres.StringColumn("applied_in_version") + allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn, DispositionColumn, ResolutionNoteColumn, ResolvedAtColumn, AppliedInVersionColumn} + mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn, DispositionColumn, ResolutionNoteColumn, ResolvedAtColumn, AppliedInVersionColumn} + defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn, DispositionColumn, ResolutionNoteColumn, AppliedInVersionColumn} ) return complaintsTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - ComplaintID: ComplaintIDColumn, - ComplainantID: ComplainantIDColumn, - GameID: GameIDColumn, - Variant: VariantColumn, - DictVersion: DictVersionColumn, - Word: WordColumn, - WasValid: WasValidColumn, - Note: NoteColumn, - Status: StatusColumn, - CreatedAt: CreatedAtColumn, + ComplaintID: ComplaintIDColumn, + ComplainantID: ComplainantIDColumn, + GameID: GameIDColumn, + Variant: VariantColumn, + DictVersion: DictVersionColumn, + Word: WordColumn, + WasValid: WasValidColumn, + Note: NoteColumn, + Status: StatusColumn, + CreatedAt: CreatedAtColumn, + Disposition: DispositionColumn, + ResolutionNote: ResolutionNoteColumn, + ResolvedAt: ResolvedAtColumn, + AppliedInVersion: AppliedInVersionColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00008_complaints_resolution.sql b/backend/internal/postgres/migrations/00008_complaints_resolution.sql new file mode 100644 index 0000000..08d3eba --- /dev/null +++ b/backend/internal/postgres/migrations/00008_complaints_resolution.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- Stage 10 admin & dictionary ops: the word-check complaint resolution lifecycle. +-- Stage 3 created complaints with a free-form status (only ever 'open'); the admin +-- review queue (this stage) resolves them with a disposition that also feeds the +-- offline dictionary-rebuild pipeline: an accepted complaint records whether the +-- word should be added or removed, and is marked applied once a rebuilt dictionary +-- version is hot-reloaded. No operator identity is recorded (the gateway gates the +-- console behind Basic-Auth; the backend keeps no admin principal). Adds columns, so +-- the generated jet code is regenerated (cmd/jetgen). +SET search_path = backend, pg_catalog; + +ALTER TABLE complaints + ADD COLUMN disposition text NOT NULL DEFAULT '', + ADD COLUMN resolution_note text NOT NULL DEFAULT '', + ADD COLUMN resolved_at timestamptz, + ADD COLUMN applied_in_version text NOT NULL DEFAULT '', + ADD CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')), + ADD CONSTRAINT complaints_disposition_chk + CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove')); + +-- +goose Down +SET search_path = backend, pg_catalog; + +ALTER TABLE complaints + DROP CONSTRAINT complaints_disposition_chk, + DROP CONSTRAINT complaints_status_chk, + DROP COLUMN applied_in_version, + DROP COLUMN resolved_at, + DROP COLUMN resolution_note, + DROP COLUMN disposition; diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index f3a4b98..113ae11 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -87,7 +87,6 @@ func (s *Server) registerRoutes() { u.POST("/blocks", s.handleBlock) u.DELETE("/blocks/:id", s.handleUnblock) } - s.admin.GET("/ping", s.handleAdminPing) } // userID returns the authenticated account id stored by RequireUserID. The user diff --git a/backend/internal/server/handlers_admin.go b/backend/internal/server/handlers_admin.go deleted file mode 100644 index 81b5adf..0000000 --- a/backend/internal/server/handlers_admin.go +++ /dev/null @@ -1,16 +0,0 @@ -package server - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -// The /api/v1/admin/* endpoints are reached through the gateway's Basic-Auth -// reverse proxy (docs/ARCHITECTURE.md §12). The backend trusts the gateway to -// have authenticated the operator; the admin surface itself (complaint review, -// dictionary versions) lands in Stage 10. handleAdminPing is the proxy target that -// proves the path end to end until then. -func (s *Server) handleAdminPing(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go new file mode 100644 index 0000000..81f6111 --- /dev/null +++ b/backend/internal/server/handlers_admin_console.go @@ -0,0 +1,458 @@ +package server + +import ( + "bytes" + "fmt" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// adminPageSize is the page size of the admin console's paginated lists. +const adminPageSize = 50 + +// registerConsole mounts the server-rendered admin console under /_gm. The gateway +// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the +// backend trusts the gateway (as for all of /api) and adds only a same-origin guard +// on the state-changing POSTs (docs/ARCHITECTURE.md §12). The console reads the +// account, game and dictionary surfaces, so it mounts only when those are wired. +func (s *Server) registerConsole(router *gin.Engine) { + if s.accounts == nil || s.games == nil || s.registry == nil { + return + } + s.console = adminconsole.MustNewRenderer() + assets, err := adminconsole.Assets() + if err != nil { + panic(err) + } + + gm := router.Group("/_gm") + gm.Use(requireSameOrigin()) + gm.StaticFS("/assets", http.FS(assets)) + gm.GET("/", s.consoleDashboard) + gm.GET("/users", s.consoleUsers) + gm.GET("/users/:id", s.consoleUserDetail) + gm.POST("/users/:id/message", s.consoleUserMessage) + gm.GET("/games", s.consoleGames) + gm.GET("/games/:id", s.consoleGameDetail) + gm.GET("/complaints", s.consoleComplaints) + gm.GET("/complaints/:id", s.consoleComplaintDetail) + gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint) + gm.GET("/dictionary", s.consoleDictionary) + gm.POST("/dictionary/reload", s.consoleReloadDictionary) + gm.POST("/dictionary/changes/apply", s.consoleApplyChanges) + gm.GET("/broadcast", s.consoleBroadcast) + gm.POST("/broadcast", s.consolePostBroadcast) +} + +// consoleDashboard renders the landing page: the top-line counts and the resident +// dictionary versions. +func (s *Server) consoleDashboard(c *gin.Context) { + ctx := c.Request.Context() + view := adminconsole.DashboardView{Variants: s.variantVersions()} + view.Accounts, _ = s.accounts.CountAccounts(ctx) + view.Games, _ = s.games.CountGames(ctx, "") + view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive) + view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen) + if changes, err := s.games.DictionaryChanges(ctx); err == nil { + view.PendingChanges = len(changes) + } + s.renderConsole(c, "dashboard", "dashboard", "Dashboard", view) +} + +// consoleUsers renders the paginated account list. +func (s *Server) consoleUsers(c *gin.Context) { + ctx := c.Request.Context() + page := consolePage(c) + total, _ := s.accounts.CountAccounts(ctx) + accs, err := s.accounts.ListAccounts(ctx, adminPageSize, (page-1)*adminPageSize) + if err != nil { + s.consoleError(c, err) + return + } + view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)} + for _, a := range accs { + kind := "registered" + if a.IsGuest { + kind = "guest" + } + view.Items = append(view.Items, adminconsole.UserRow{ + ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind, + Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt), + }) + } + s.renderConsole(c, "users", "users", "Users", view) +} + +// consoleUserDetail renders one account with its stats, identities and games. +func (s *Server) consoleUserDetail(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + acc, err := s.accounts.GetByID(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + view := adminconsole.UserDetailView{ + ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, + TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly, + HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, + ConnectorEnabled: s.connector != nil, + } + if view.HasStats { + if st, err := s.accounts.GetStats(ctx, id); err == nil { + view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints} + } + } + if ids, err := s.accounts.Identities(ctx, id); err == nil { + for _, idn := range ids { + view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)}) + } + } + if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil { + view.TelegramID = tg + } + if games, err := s.games.ListForAccount(ctx, id); err == nil { + for _, g := range games { + view.Games = append(view.Games, gameRow(g)) + } + } + s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) +} + +// consoleUserMessage sends an operator Telegram message to one user. +func (s *Server) consoleUserMessage(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + text := trimForm(c, "text") + switch { + case text == "": + s.renderConsoleMessage(c, "Nothing sent", "the message was empty", back) + case s.connector == nil: + s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", back) + default: + ext, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram) + if err != nil { + s.renderConsoleMessage(c, "No Telegram", "this account has no Telegram identity", back) + return + } + delivered, err := s.connector.SendToUser(ctx, ext, text) + if err != nil { + s.consoleError(c, err) + return + } + body := "message delivered" + if !delivered { + body = "not delivered (the user may not have started the bot)" + } + s.renderConsoleMessage(c, "Sent", body, back) + } +} + +// consoleGames renders the paginated games list, optionally filtered by status. +func (s *Server) consoleGames(c *gin.Context) { + ctx := c.Request.Context() + status := normalizeGameStatus(c.Query("status")) + page := consolePage(c) + total, _ := s.games.CountGames(ctx, status) + games, err := s.games.ListGames(ctx, status, adminPageSize, (page-1)*adminPageSize) + if err != nil { + s.consoleError(c, err) + return + } + view := adminconsole.GamesView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)} + for _, g := range games { + view.Items = append(view.Items, gameRow(g)) + } + s.renderConsole(c, "games", "games", "Games", view) +} + +// consoleGameDetail renders one game with its seats (display names resolved). +func (s *Server) consoleGameDetail(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/games") + if !ok { + return + } + g, err := s.games.GameByID(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + view := adminconsole.GameDetailView{ + ID: g.ID.String(), Variant: g.Variant.String(), DictVersion: g.DictVersion, + Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason, + MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt), + FinishedAt: fmtTimePtr(g.FinishedAt), + } + for _, seat := range g.Seats { + row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner} + if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil { + row.DisplayName = acc.DisplayName + } + view.Seats = append(view.Seats, row) + } + s.renderConsole(c, "game_detail", "games", "Game", view) +} + +// consoleComplaints renders the paginated complaint review queue. +func (s *Server) consoleComplaints(c *gin.Context) { + ctx := c.Request.Context() + status := normalizeComplaintStatus(c.Query("status")) + page := consolePage(c) + total, _ := s.games.CountComplaints(ctx, status) + rows, err := s.games.ListComplaints(ctx, status, adminPageSize, (page-1)*adminPageSize) + if err != nil { + s.consoleError(c, err) + return + } + view := adminconsole.ComplaintsView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)} + for _, cp := range rows { + view.Items = append(view.Items, adminconsole.ComplaintRow{ + ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), WasValid: cp.WasValid, + Status: cp.Status, Disposition: cp.Disposition, CreatedAt: fmtTime(cp.CreatedAt), + }) + } + s.renderConsole(c, "complaints", "complaints", "Complaints", view) +} + +// consoleComplaintDetail renders one complaint with its resolution form. +func (s *Server) consoleComplaintDetail(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/complaints") + if !ok { + return + } + cp, err := s.games.GetComplaint(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsole(c, "complaint_detail", "complaints", "Complaint", adminconsole.ComplaintDetailView{ + ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), DictVersion: cp.DictVersion, + WasValid: cp.WasValid, Note: cp.Note, Status: cp.Status, Disposition: cp.Disposition, + ResolutionNote: cp.ResolutionNote, CreatedAt: fmtTime(cp.CreatedAt), ResolvedAt: fmtTimePtr(cp.ResolvedAt), + GameID: cp.GameID.String(), Resolved: cp.Status == game.StatusComplaintResolved, + }) +} + +// consoleResolveComplaint resolves a complaint with the chosen disposition. +func (s *Server) consoleResolveComplaint(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/complaints") + if !ok { + return + } + disposition := c.PostForm("disposition") + if _, err := s.games.ResolveComplaint(ctx, id, disposition, trimForm(c, "note")); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Resolved", "complaint resolved as "+disposition, "/_gm/complaints/"+id.String()) +} + +// consoleDictionary renders the resident versions and the pending wordlist changes. +func (s *Server) consoleDictionary(c *gin.Context) { + view := adminconsole.DictionaryView{Variants: s.variantVersions()} + if changes, err := s.games.DictionaryChanges(c.Request.Context()); err == nil { + for _, ch := range changes { + action := "remove" + if ch.Add { + action = "add" + } + view.Changes = append(view.Changes, adminconsole.DictChangeRow{Variant: ch.Variant.String(), Word: ch.Word, Action: action, ResolvedAt: fmtTime(ch.ResolvedAt)}) + } + } + s.renderConsole(c, "dictionary", "dictionary", "Dictionary", view) +} + +// consoleReloadDictionary hot-loads a dictionary version from its subdirectory. +func (s *Server) consoleReloadDictionary(c *gin.Context) { + version := trimForm(c, "version") + if version == "" { + s.renderConsoleMessage(c, "Reload failed", "a version is required", "/_gm/dictionary") + return + } + dir := filepath.Join(s.dictDir, version) + loaded, err := s.registry.LoadAvailable(dir, version) + if err != nil { + s.consoleError(c, err) + return + } + if len(loaded) == 0 { + s.renderConsoleMessage(c, "Nothing loaded", "no dictionary files found in "+dir, "/_gm/dictionary") + return + } + names := make([]string, len(loaded)) + for i, v := range loaded { + names[i] = v.String() + } + s.renderConsoleMessage(c, "Reloaded", fmt.Sprintf("loaded %v as version %q", names, version), "/_gm/dictionary") +} + +// consoleApplyChanges marks a variant's pending accepted changes applied in a +// reloaded version. +func (s *Server) consoleApplyChanges(c *gin.Context) { + variant, err := engine.ParseVariant(c.PostForm("variant")) + if err != nil { + s.renderConsoleMessage(c, "Apply failed", "unknown variant", "/_gm/dictionary") + return + } + version := trimForm(c, "version") + if version == "" { + s.renderConsoleMessage(c, "Apply failed", "a version is required", "/_gm/dictionary") + return + } + n, err := s.games.MarkChangesApplied(c.Request.Context(), variant, version) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Applied", fmt.Sprintf("marked %d change(s) applied in %q", n, version), "/_gm/dictionary") +} + +// consoleBroadcast renders the operator-broadcast form. +func (s *Server) consoleBroadcast(c *gin.Context) { + s.renderConsole(c, "broadcast", "broadcast", "Broadcast", adminconsole.BroadcastView{ConnectorEnabled: s.connector != nil}) +} + +// consolePostBroadcast posts an operator message to the connector's game channel. +func (s *Server) consolePostBroadcast(c *gin.Context) { + text := trimForm(c, "text") + switch { + case text == "": + s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast") + case s.connector == nil: + s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast") + default: + delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text) + if err != nil { + s.consoleError(c, err) + return + } + body := "posted to the game channel" + if !delivered { + body = "not delivered (no game channel configured on the connector)" + } + s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast") + } +} + +// variantVersions builds the per-variant resident-version summary from the registry. +func (s *Server) variantVersions() []adminconsole.VariantVersions { + out := make([]adminconsole.VariantVersions, 0, len(engine.Variants())) + for _, v := range engine.Variants() { + vv := adminconsole.VariantVersions{Variant: v.String(), Versions: s.registry.Versions(v)} + if latest, _, err := s.registry.Latest(v); err == nil { + vv.Latest = latest + } + out = append(out, vv) + } + return out +} + +// renderConsole renders a console page into a buffer, then writes it; a render +// failure is logged and reported as 500 without emitting a partial document. +func (s *Server) renderConsole(c *gin.Context, page, nav, title string, data any) { + var buf bytes.Buffer + if err := s.console.Render(&buf, page, adminconsole.PageData{Title: title, ActiveNav: nav, Data: data}); err != nil { + s.log.Error("admin console render", zap.String("page", page), zap.Error(err)) + c.String(http.StatusInternalServerError, "render error") + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) +} + +// renderConsoleMessage renders the post-action result page with a back link. +func (s *Server) renderConsoleMessage(c *gin.Context, heading, body, back string) { + s.renderConsole(c, "message", "", heading, adminconsole.MessageView{Heading: heading, Body: body, Back: back}) +} + +// consoleError logs an unexpected console error and renders it on the message page. +// The console is operator-only (gateway Basic-Auth), so the message is shown as-is. +func (s *Server) consoleError(c *gin.Context, err error) { + s.log.Error("admin console", zap.String("path", c.FullPath()), zap.Error(err)) + s.renderConsole(c, "message", "", "Error", adminconsole.MessageView{Heading: "Error", Body: err.Error(), Back: "/_gm/"}) +} + +// consoleUUID parses the :id path parameter, rendering an invalid-id message and +// returning false when it is not a UUID. +func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + s.renderConsoleMessage(c, "Invalid id", "the id in the URL is not valid", back) + return uuid.UUID{}, false + } + return id, true +} + +// gameRow projects a game summary into its console row. +func gameRow(g game.Game) adminconsole.GameRow { + return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt)} +} + +// trimForm returns the trimmed value of a posted form field. +func trimForm(c *gin.Context, name string) string { + return strings.TrimSpace(c.PostForm(name)) +} + +// consolePage parses the 1-based ?page query parameter, defaulting to 1. +func consolePage(c *gin.Context) int { + if n, err := strconv.Atoi(c.Query("page")); err == nil && n > 1 { + return n + } + return 1 +} + +// normalizeGameStatus keeps only a recognised game status filter, else "" (all). +func normalizeGameStatus(s string) string { + switch s { + case game.StatusActive, game.StatusFinished: + return s + } + return "" +} + +// normalizeComplaintStatus keeps only a recognised complaint status filter, else +// "" (all). +func normalizeComplaintStatus(s string) string { + switch s { + case game.StatusComplaintOpen, game.StatusComplaintResolved: + return s + } + return "" +} + +// fmtTime formats a timestamp for display, or "" when zero. +func fmtTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02 15:04") +} + +// fmtTimePtr formats an optional timestamp for display, or "" when nil. +func fmtTimePtr(t *time.Time) string { + if t == nil { + return "" + } + return fmtTime(*t) +} diff --git a/backend/internal/server/handlers_test.go b/backend/internal/server/handlers_test.go index c11afbe..4d9c8b1 100644 --- a/backend/internal/server/handlers_test.go +++ b/backend/internal/server/handlers_test.go @@ -43,13 +43,6 @@ func do(t *testing.T, s *Server, method, path, body string, headers map[string]s return rec } -func TestAdminPingOK(t *testing.T) { - rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/admin/ping", "", nil) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"status":"ok"`) { - t.Fatalf("admin ping = %d %q", rec.Code, rec.Body.String()) - } -} - func TestProfileRequiresUserID(t *testing.T) { rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil) if rec.Code != http.StatusUnauthorized { diff --git a/backend/internal/server/middleware.go b/backend/internal/server/middleware.go index d249b06..f1a1e84 100644 --- a/backend/internal/server/middleware.go +++ b/backend/internal/server/middleware.go @@ -3,6 +3,7 @@ package server import ( "context" "net/http" + "net/url" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -39,3 +40,40 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(userIDContextKey).(uuid.UUID) return id, ok } + +// requireSameOrigin guards the admin console's state-changing requests: it rejects +// a non-safe request whose Origin (or, failing that, Referer) host does not match +// the request Host. The gateway authenticates the operator with Basic-Auth in front +// of /_gm; this same-origin check is the console's CSRF defence, stopping a +// cross-site form POST from riding the cached credential. Safe methods pass through. +func requireSameOrigin() gin.HandlerFunc { + return func(c *gin.Context) { + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + c.Next() + return + } + if !sameOrigin(c.Request) { + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Next() + } +} + +// sameOrigin reports whether the request's Origin (or, failing that, Referer) host +// matches the request Host. A state-changing request carrying neither header is +// rejected. +func sameOrigin(r *http.Request) bool { + for _, h := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} { + if h == "" { + continue + } + u, err := url.Parse(h) + if err != nil { + return false + } + return u.Host == r.Host + } + return false +} diff --git a/backend/internal/server/middleware_console_test.go b/backend/internal/server/middleware_console_test.go new file mode 100644 index 0000000..66d63c7 --- /dev/null +++ b/backend/internal/server/middleware_console_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +// TestSameOriginGuard checks the admin console's CSRF defence: safe methods pass, +// a state-changing request needs an Origin/Referer host matching the request Host. +func TestSameOriginGuard(t *testing.T) { + gin.SetMode(gin.TestMode) + e := gin.New() + g := e.Group("/_gm") + g.Use(requireSameOrigin()) + g.POST("/act", func(c *gin.Context) { c.Status(http.StatusOK) }) + g.GET("/page", func(c *gin.Context) { c.Status(http.StatusOK) }) + + cases := []struct { + name string + method string + path string + origin string + referer string + want int + }{ + {"get is safe", http.MethodGet, "/_gm/page", "", "", http.StatusOK}, + {"post without origin rejected", http.MethodPost, "/_gm/act", "", "", http.StatusForbidden}, + {"post matching origin ok", http.MethodPost, "/_gm/act", "http://example.com", "", http.StatusOK}, + {"post foreign origin rejected", http.MethodPost, "/_gm/act", "http://evil.test", "", http.StatusForbidden}, + {"post matching referer ok", http.MethodPost, "/_gm/act", "", "http://example.com/_gm/x", http.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, "http://example.com"+tc.path, nil) + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + if tc.referer != "" { + req.Header.Set("Referer", tc.referer) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Errorf("status = %d, want %d", rec.Code, tc.want) + } + }) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 5598a79..d081b01 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -18,6 +18,9 @@ import ( "go.uber.org/zap" "scrabble/backend/internal/account" + "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/connector" + "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/session" @@ -55,6 +58,15 @@ type Deps struct { Matchmaker *lobby.Matchmaker Invitations *lobby.InvitationService Emails *account.EmailService + // Registry holds the resident dictionaries; the admin console (Stage 10) reads + // its versions and hot-reloads new ones. DictDir is the dictionary directory a + // reload reads a version subdirectory from. A nil Registry disables the console. + Registry *engine.Registry + DictDir string + // Connector is the backend's Telegram connector client for operator broadcasts; + // nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured" + // notice). + Connector *connector.Client } // Server owns the gin engine, the underlying HTTP server and the readiness @@ -73,11 +85,14 @@ type Server struct { matchmaker *lobby.Matchmaker invitations *lobby.InvitationService emails *account.EmailService + registry *engine.Registry + dictDir string + connector *connector.Client + console *adminconsole.Renderer public *gin.RouterGroup user *gin.RouterGroup internal *gin.RouterGroup - admin *gin.RouterGroup } // New returns a Server that will listen on addr. It installs the recovery and @@ -109,11 +124,15 @@ func New(addr string, deps Deps) *Server { matchmaker: deps.Matchmaker, invitations: deps.Invitations, emails: deps.Emails, + registry: deps.Registry, + dictDir: deps.DictDir, + connector: deps.Connector, http: &http.Server{Addr: addr, Handler: engine}, } s.registerProbes(engine) s.registerAPIGroups(engine) s.registerRoutes() + s.registerConsole(engine) return s } @@ -153,7 +172,6 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) { s.user = v1.Group("/user") s.user.Use(RequireUserID()) s.internal = v1.Group("/internal") - s.admin = v1.Group("/admin") } // PublicGroup returns the unauthenticated public route group. @@ -165,9 +183,6 @@ func (s *Server) UserGroup() *gin.RouterGroup { return s.user } // InternalGroup returns the gateway-facing internal route group. func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal } -// AdminGroup returns the admin route group (authenticated at the gateway). -func (s *Server) AdminGroup() *gin.RouterGroup { return s.admin } - // Social returns the social domain service for the handlers added in Stage 6. func (s *Server) Social() *social.Service { return s.social } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1ab61f3..1f3c4a4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,8 +14,9 @@ Three executables plus per-platform side-services: - **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs anti-abuse (rate limiting), authenticates the player against the originating platform (or an email/guest session), resolves the internal `user_id`, and - forwards authenticated traffic to `backend` with an `X-User-ID` header. Hosts an - admin surface behind HTTP Basic Auth. Bridges live events from `backend` to the + forwards authenticated traffic to `backend` with an `X-User-ID` header. Serves the + backend's admin console at `/_gm` on its public listener behind HTTP Basic Auth. + Bridges live events from `backend` to the client. The shared wire contracts (the push proto and the FlatBuffers edge payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`. - **`backend`** — internal-only service that owns every domain concern: @@ -47,7 +48,7 @@ Three executables plus per-platform side-services: `scrabble/platform/telegram`). It is the only component holding the bot token: it runs the Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation - and out-of-app push) and `backend` (admin messaging — Stage 10) call over the + and out-of-app push) and `backend` (operator broadcasts) call over the trusted internal network. Its generic delivery methods are **platform-agnostic** (keyed by the identity `external_id`), so a future VK/MAX connector reuses them; only initData validation is Telegram-specific. It runs in its own container, egressing to @@ -62,7 +63,7 @@ flowchart LR Backend -- pgx --> Postgres[(Postgres)] Backend -. embeds .- Solver[[scrabble-solver library]] Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector] - Backend -. admin gRPC, Stage 10 .-> Telegram + Backend -. operator broadcasts (gRPC) .-> Telegram Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram)) ``` @@ -166,11 +167,15 @@ Key points: word-check tool through `Registry.Lookup`. - **Dictionary versioning — pin per game.** A game records the `dict_version` it started on and finishes on that version; new games use the latest. Multiple - versions may be resident at once. An admin reload *(planned, Stage 10)* - registers a new version through `Registry.Load`; delivery is the DAWG file in - the image / a volume mounted at the dictionary directory. (A future split of - the solver into engine + dictionary generator with versioned artifacts is - recorded in [`../PLAN.md`](../PLAN.md) TODO-2.) + versions may be resident at once. The boot version loads from the flat + `BACKEND_DICT_DIR`; the admin console **hot-reloads** a new version from a + per-version subdirectory `BACKEND_DICT_DIR//` through + `Registry.LoadAvailable` (only the variants whose DAWG is present there), and a + restart re-loads every resident version via `engine.OpenWithVersions` (the flat + boot version plus each subdirectory). In-flight games keep their pinned version; + new games use the latest. (A future split of the solver into engine + dictionary + generator with versioned artifacts is recorded in [`../PLAN.md`](../PLAN.md) + TODO-2.) - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses `scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag** @@ -231,8 +236,11 @@ Key points: "no options" rather than "no hints left". - **Word-check tool**: unlimited dictionary lookups against the game's pinned dictionary; each result offers a **complaint** (complainant, game, variant, - dict_version, word, the disputed result, an optional note) that lands in an - admin review queue *(admin side planned, Stage 10)*. + dict_version, word, the disputed result, an optional note) that lands in the admin + review queue. An operator resolves it (`open → resolved`) with a **disposition** — + reject, accept-add or accept-remove; the accepted ones form a derived + **pending-changes** list that feeds the offline dictionary rebuild and is marked + applied once the rebuilt version is hot-reloaded (§5, §12). ## 7. Robot opponent @@ -439,7 +447,7 @@ promotions) is future work and would deliver short markdown messages (text + lin | Session minting; email-code / guest validation | gateway (with backend) | | Session → `user_id` resolution, `X-User-ID` injection | gateway | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | -| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`), then reverse-proxies to backend admin endpoints | +| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`) on the public `/_gm/*` path and reverse-proxies it **verbatim** to the backend's server-rendered admin console; the backend trusts the gateway (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked | | backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) | This is an explicit, accepted MVP risk: compromise of the gateway↔backend @@ -459,8 +467,9 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini App under `/telegram/`** (the gateway serves the static UI build; outside Telegram -that path redirects to the root), the gateway public surface and the admin surface -share one host that terminates TLS. The **Telegram connector** runs as a separate +that path redirects to the root), the gateway public surface and the **admin console +at `/_gm`** (backend-rendered, Basic-Auth at the gateway) share one host that +terminates TLS. The **Telegram connector** runs as a separate container with **no public ingress** — it long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC. MVP runs one `gateway`, one `backend`, one Postgres, plus the connector. The connector's Docker/compose ships now diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 68d5562..66cb323 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -110,5 +110,15 @@ 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). ### Administration *(Stage 10)* -Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary -versions, and inspects users/games. +Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend +renders it; the gateway gates it with HTTP Basic Auth on its public listener and +proxies it verbatim. The console lists and inspects **users** (profile, statistics, +identities, their games) and **games** (summary + seats), works the **word-complaint +review queue** — resolving each as reject / accept-add / accept-remove — and exposes +the **dictionary**: the resident versions per variant, a **hot-reload** of a new +version from `BACKEND_DICT_DIR//`, and the **pending wordlist changes** +derived from accepted complaints (which feed the offline rebuild and are marked +applied after a reload). When a Telegram connector is configured an operator can also +**message a user** (by their Telegram identity) or **post to the game channel**. +State-changing actions are protected by a same-origin check; the console tracks no +operator identity. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 344bb45..680cb2c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -113,5 +113,14 @@ push доставляется через платформу. ход, уже включающий все образованные им слова и бонус за все фишки). ### Администрирование *(Stage 10)* -Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями -словаря, смотрит пользователей/игры. +Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит +backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует +один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика, +identity, их игры) и **игры** (сводка + места), разбирать **очередь жалоб на слова** — +закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**: +резидентные версии по вариантам, **горячая перезагрузка** новой версии из +`BACKEND_DICT_DIR//` и **список ожидающих правок**, выведенный из принятых +жалоб (он питает офлайн-пересборку и отмечается применённым после перезагрузки). Если +подключён Telegram-коннектор, оператор также может **написать пользователю** (по его +Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия +защищены проверкой same-origin; личность оператора не отслеживается. diff --git a/docs/TESTING.md b/docs/TESTING.md index a0c135f..b23d4fb 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -82,6 +82,17 @@ tests or touching CI. in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account operations (friends list, friend code issue/redeem, invitation create, stats, GCG, the profile-update away round-trip) and a `notify`-event constructor round-trip. +- **Admin & dictionary ops** *(Stage 10)* — `backend/internal/adminconsole` unit-tests + the template renderer over every page plus the embedded asset; `backend/internal/engine` + adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present + variants, `OpenWithVersions` scans version subdirectories, a reload registers a new + version and moves "latest"); `backend/internal/server` unit-tests the console's + **same-origin** CSRF guard; the gateway adds the **verbatim `/_gm` Basic-Auth proxy** + (401 / forward, path preserved) and the h2c **console mount** (routed when configured, + 404 when not). Postgres-backed `inttest` drives the **complaint resolution → + dictionary-change pipeline** (file → resolve with a disposition → pending change → mark + applied), the admin **list/count** read queries, and the **/_gm console over HTTP** + (pages render; a resolve POST needs a same-origin header). ## Principles diff --git a/gateway/README.md b/gateway/README.md index 957184d..8aa4fc4 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -5,9 +5,9 @@ terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2 cleartext (`h2c`), authenticates the originating credential, mints/resolves a thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the backend over REST/JSON, and bridges the backend's gRPC push stream to each -client's in-app live channel. It also fronts the backend admin API behind HTTP -Basic-Auth. See [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, -§12. +client's in-app live channel. It also serves the backend's admin console at `/_gm` +on its public listener behind HTTP Basic-Auth. See +[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12. ## Package layout @@ -23,7 +23,7 @@ internal/connector/ # gRPC client to the Telegram connector (initData valida internal/push/ # live-event fan-out hub (per-user client streams) internal/transcode/ # FlatBuffers<->REST bridge + message_type registry internal/connectsrv/ # the Connect Gateway service over h2c -internal/admin/ # Basic-Auth reverse proxy to the backend admin API +internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim) ``` The FlatBuffers payloads and the backend push proto are the shared wire @@ -58,14 +58,13 @@ identical transcode pattern (`transcode_social.go`). | Variable | Default | Notes | | --- | --- | --- | -| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener | -| `GATEWAY_ADMIN_ADDR` | `:8082` | admin proxy listener (enabled only with creds) | +| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener (also serves the admin console at `/_gm`) | | `GATEWAY_LOG_LEVEL` | `info` | zap level | | `GATEWAY_BACKEND_HTTP_URL` | `http://localhost:8080` | backend REST base URL | | `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address | | `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call | -| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin proxy | -| `GATEWAY_TELEGRAM_BOT_TOKEN` | unset | enable the Telegram auth path | +| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` | +| `GATEWAY_CONNECTOR_ADDR` | unset | Telegram connector gRPC address (enables initData validation + out-of-app push) | | `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime | | `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap | | `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive | diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 75efb06..766c431 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -2,8 +2,8 @@ // the client's Connect-RPC/FlatBuffers traffic over h2c, validates platform / // email / guest credentials and mints opaque sessions, rate-limits, injects // X-User-ID when forwarding to the backend over REST, and bridges the backend's -// gRPC push stream to each client's in-app live channel. It also fronts the -// backend admin API behind HTTP Basic-Auth. +// gRPC push stream to each client's in-app live channel. It also serves the +// backend's admin console at /_gm on the public listener behind HTTP Basic-Auth. package main import ( @@ -57,8 +57,8 @@ func main() { } } -// run wires the gateway dependencies and serves the public (and optional admin) -// listeners until the context is cancelled. +// run wires the gateway dependencies and serves the public listener (which also +// fronts the admin console at /_gm) until the context is cancelled. func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -86,15 +86,29 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Warn("telegram disabled (GATEWAY_CONNECTOR_ADDR unset)") } + // The admin console (backend /_gm) is fronted on the public listener behind + // Basic-Auth, enabled when both credentials are set; it is mounted on the edge + // mux so the Connect h2c handler stays the top-level handler. + var adminProxy http.Handler + if cfg.AdminEnabled() { + adminProxy, err = admin.NewProxy(cfg.BackendHTTPURL, cfg.AdminUser, cfg.AdminPassword, logger) + if err != nil { + return err + } + } else { + logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)") + } + registry := transcode.NewRegistry(backend, validator) edge := connectsrv.NewServer(connectsrv.Deps{ - Registry: registry, - Sessions: sessions, - Limiter: limiter, - Hub: hub, - RateLimit: cfg.RateLimit, - Heartbeat: cfg.PushHeartbeatInterval, - Logger: logger, + Registry: registry, + Sessions: sessions, + Limiter: limiter, + Hub: hub, + RateLimit: cfg.RateLimit, + Heartbeat: cfg.PushHeartbeatInterval, + Logger: logger, + AdminProxy: adminProxy, }) // Bridge the backend push stream into the fan-out hub (and the out-of-app @@ -104,16 +118,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()} servers := []*namedServer{{name: "public", srv: public}} - if cfg.AdminEnabled() { - proxy, err := admin.NewProxy(cfg.BackendHTTPURL, cfg.AdminUser, cfg.AdminPassword, logger) - if err != nil { - return err - } - servers = append(servers, &namedServer{name: "admin", srv: &http.Server{Addr: cfg.AdminAddr, Handler: proxy}}) - } else { - logger.Info("admin proxy disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)") - } - logger.Info("gateway starting", zap.String("http_addr", cfg.HTTPAddr), zap.String("backend_http", cfg.BackendHTTPURL), diff --git a/gateway/internal/admin/admin.go b/gateway/internal/admin/admin.go index ced600c..077df3e 100644 --- a/gateway/internal/admin/admin.go +++ b/gateway/internal/admin/admin.go @@ -1,8 +1,11 @@ -// Package admin is the gateway's admin surface: HTTP Basic-Auth in front of a -// reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway -// validates the operator credential and forwards authenticated requests to -// backend /api/v1/admin/*; the backend trusts the gateway on this segment. The -// admin API itself is filled in Stage 10. +// Package admin is the gateway's admin edge: HTTP Basic-Auth in front of a reverse +// proxy that forwards the operator's browser to the backend's server-rendered admin +// console under /_gm. The proxy is mounted at /_gm/ on the gateway's public listener +// (below the h2c wrap, see internal/connectsrv) and forwards verbatim — an inbound +// /_gm/ reaches /_gm/, preserving the inbound Host so the +// backend's same-origin check sees the public origin. The backend trusts the gateway +// on this segment and adds the console's same-origin CSRF guard +// (docs/ARCHITECTURE.md §12). package admin import ( @@ -11,17 +14,14 @@ import ( "net/http" "net/http/httputil" "net/url" - "strings" "go.uber.org/zap" ) -// backendAdminPrefix is where the backend mounts its admin API. -const backendAdminPrefix = "/api/v1/admin" - // NewProxy returns a handler that checks Basic-Auth against user/password and -// reverse-proxies the request to the backend admin API, mapping an inbound -// /admin/ path to /api/v1/admin/. +// reverse-proxies the request verbatim to the backend: the inbound path is +// preserved, so /_gm/ reaches /_gm/. It is mounted at /_gm/ +// on the gateway's public listener. func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, error) { target, err := url.Parse(backendURL) if err != nil { @@ -32,10 +32,8 @@ func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, } proxy := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { - pr.SetURL(target) - rel := strings.TrimPrefix(pr.In.URL.Path, "/admin") - pr.Out.URL.Path = backendAdminPrefix + rel - pr.Out.Host = pr.In.Host + pr.SetURL(target) // backend scheme+host; the inbound /_gm path is preserved + pr.Out.Host = pr.In.Host // keep the public Host for the backend same-origin check }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { log.Warn("admin proxy upstream error", zap.String("path", r.URL.Path), zap.Error(err)) diff --git a/gateway/internal/admin/admin_test.go b/gateway/internal/admin/admin_test.go index f2193b7..28c4193 100644 --- a/gateway/internal/admin/admin_test.go +++ b/gateway/internal/admin/admin_test.go @@ -9,27 +9,28 @@ import ( "scrabble/gateway/internal/admin" ) -func newAdmin(t *testing.T) (*httptest.Server, func()) { +// newAdmin fronts a fake backend with the admin proxy. The fake backend records the +// path it receives so a test can assert the proxy forwards /_gm verbatim. +func newAdmin(t *testing.T) (front *httptest.Server, gotPath *string, cleanup func()) { t.Helper() + var path string backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/admin/ping" { - t.Errorf("backend path = %q, want /api/v1/admin/ping", r.URL.Path) - } - _, _ = w.Write([]byte("pong")) + path = r.URL.Path + _, _ = w.Write([]byte("console")) })) proxy, err := admin.NewProxy(backend.URL, "ops", "secret", nil) if err != nil { t.Fatalf("new proxy: %v", err) } - front := httptest.NewServer(proxy) - return front, func() { front.Close(); backend.Close() } + front = httptest.NewServer(proxy) + return front, &path, func() { front.Close(); backend.Close() } } func TestAdminRejectsMissingCredentials(t *testing.T) { - front, cleanup := newAdmin(t) + front, _, cleanup := newAdmin(t) defer cleanup() - resp, err := http.Get(front.URL + "/admin/ping") + resp, err := http.Get(front.URL + "/_gm/") if err != nil { t.Fatal(err) } @@ -37,13 +38,16 @@ func TestAdminRejectsMissingCredentials(t *testing.T) { if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", resp.StatusCode) } + if resp.Header.Get("WWW-Authenticate") == "" { + t.Error("missing WWW-Authenticate challenge") + } } -func TestAdminProxiesWithCredentials(t *testing.T) { - front, cleanup := newAdmin(t) +func TestAdminProxiesVerbatimWithCredentials(t *testing.T) { + front, gotPath, cleanup := newAdmin(t) defer cleanup() - req, _ := http.NewRequest(http.MethodGet, front.URL+"/admin/ping", nil) + req, _ := http.NewRequest(http.MethodGet, front.URL+"/_gm/complaints", nil) req.SetBasicAuth("ops", "secret") resp, err := http.DefaultClient.Do(req) if err != nil { @@ -51,16 +55,19 @@ func TestAdminProxiesWithCredentials(t *testing.T) { } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK || string(body) != "pong" { - t.Fatalf("status = %d body = %q, want 200 pong", resp.StatusCode, body) + if resp.StatusCode != http.StatusOK || string(body) != "console" { + t.Fatalf("status = %d body = %q, want 200 console", resp.StatusCode, body) + } + if *gotPath != "/_gm/complaints" { + t.Errorf("backend path = %q, want /_gm/complaints (verbatim)", *gotPath) } } func TestAdminRejectsWrongPassword(t *testing.T) { - front, cleanup := newAdmin(t) + front, _, cleanup := newAdmin(t) defer cleanup() - req, _ := http.NewRequest(http.MethodGet, front.URL+"/admin/ping", nil) + req, _ := http.NewRequest(http.MethodGet, front.URL+"/_gm/", nil) req.SetBasicAuth("ops", "wrong") resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 4da67c0..eb32ec8 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -11,11 +11,9 @@ import ( // Config holds the gateway's runtime configuration. type Config struct { - // HTTPAddr is the public Connect/h2c listener address (host:port). + // HTTPAddr is the public Connect/h2c listener address (host:port). It also + // serves the admin console at /_gm when admin credentials are configured. HTTPAddr string - // AdminAddr is the admin reverse-proxy listener address. Admin is enabled only - // when AdminUser and AdminPassword are also set. - AdminAddr string // LogLevel is the zap log level: "debug", "info", "warn" or "error". LogLevel string // BackendHTTPURL is the base URL of the backend REST API (gateway -> backend). @@ -59,7 +57,6 @@ type RateLimitConfig struct { // Defaults applied when the corresponding environment variable is unset. const ( defaultHTTPAddr = ":8081" - defaultAdminAddr = ":8082" defaultLogLevel = "info" defaultBackendHTTPURL = "http://localhost:8080" defaultBackendGRPCAddr = "localhost:9090" @@ -85,7 +82,6 @@ func Load() (Config, error) { var err error c := Config{ HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr), - AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr), LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel), BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL), BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr), @@ -113,10 +109,10 @@ func Load() (Config, error) { return c, nil } -// AdminEnabled reports whether the admin proxy should be served (an address and -// both Basic-Auth credentials are configured). +// AdminEnabled reports whether the admin console proxy should be mounted (both +// Basic-Auth credentials are configured). func (c Config) AdminEnabled() bool { - return c.AdminAddr != "" && c.AdminUser != "" && c.AdminPassword != "" + return c.AdminUser != "" && c.AdminPassword != "" } // validate reports whether the configuration values are acceptable. diff --git a/gateway/internal/connectsrv/console_mount_test.go b/gateway/internal/connectsrv/console_mount_test.go new file mode 100644 index 0000000..760014e --- /dev/null +++ b/gateway/internal/connectsrv/console_mount_test.go @@ -0,0 +1,45 @@ +package connectsrv_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "scrabble/gateway/internal/connectsrv" +) + +// TestHTTPHandlerMountsAdminConsole verifies the admin proxy is reachable at /_gm/ +// through the h2c-wrapped edge mux when configured. +func TestHTTPHandlerMountsAdminConsole(t *testing.T) { + stub := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + srv := connectsrv.NewServer(connectsrv.Deps{AdminProxy: stub}) + front := httptest.NewServer(srv.HTTPHandler()) + defer front.Close() + + resp, err := http.Get(front.URL + "/_gm/") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("/_gm status = %d, want 418 (routed to the admin proxy)", resp.StatusCode) + } +} + +// TestHTTPHandlerNoAdminConsole verifies /_gm is not served when no proxy is set. +func TestHTTPHandlerNoAdminConsole(t *testing.T) { + srv := connectsrv.NewServer(connectsrv.Deps{}) + front := httptest.NewServer(srv.HTTPHandler()) + defer front.Close() + + resp, err := http.Get(front.URL + "/_gm/") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("/_gm status = %d, want 404 (no admin proxy)", resp.StatusCode) + } +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index a8acae2..9c478b1 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -32,12 +32,13 @@ const heartbeatKind = "heartbeat" // Server implements edgev1connect.GatewayHandler. type Server struct { - registry *transcode.Registry - sessions *session.Cache - limiter *ratelimit.Limiter - hub *push.Hub - heartbeat time.Duration - log *zap.Logger + registry *transcode.Registry + sessions *session.Cache + limiter *ratelimit.Limiter + hub *push.Hub + heartbeat time.Duration + log *zap.Logger + adminProxy http.Handler publicPolicy ratelimit.Policy userPolicy ratelimit.Policy @@ -46,13 +47,14 @@ type Server struct { // Deps carries the Server's dependencies. type Deps struct { - Registry *transcode.Registry - Sessions *session.Cache - Limiter *ratelimit.Limiter - Hub *push.Hub - RateLimit config.RateLimitConfig - Heartbeat time.Duration - Logger *zap.Logger + Registry *transcode.Registry + Sessions *session.Cache + Limiter *ratelimit.Limiter + Hub *push.Hub + RateLimit config.RateLimitConfig + Heartbeat time.Duration + Logger *zap.Logger + AdminProxy http.Handler } // NewServer constructs the edge service. @@ -68,6 +70,7 @@ func NewServer(d Deps) *Server { hub: d.Hub, heartbeat: d.Heartbeat, log: log, + adminProxy: d.AdminProxy, publicPolicy: ratelimit.PerMinute(d.RateLimit.PublicPerMinute, d.RateLimit.PublicBurst), userPolicy: ratelimit.PerMinute(d.RateLimit.UserPerMinute, d.RateLimit.UserBurst), emailPolicy: ratelimit.Per(d.RateLimit.EmailPer10Min, 10*time.Minute, d.RateLimit.EmailBurst), @@ -79,6 +82,12 @@ func (s *Server) HTTPHandler() http.Handler { mux := http.NewServeMux() path, h := edgev1connect.NewGatewayHandler(s) mux.Handle(path, h) + if s.adminProxy != nil { + // The admin console (backend /_gm) is served on the public listener behind + // the proxy's Basic-Auth, mounted below the h2c wrap so the Connect edge keeps + // working over h2c (docs/ARCHITECTURE.md §12). + mux.Handle("/_gm/", s.adminProxy) + } return h2c.NewHandler(mux, &http2.Server{}) }