Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts) #11
@@ -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** |
|
| 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** |
|
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
|
||||||
| 9 | Telegram integration (bot side-service, deep-link, push) | **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 |
|
| 11 | Account linking & merge | todo |
|
||||||
| 12 | Polish (observability, perf with evidence, deploy) | 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
|
the sweeper skip when CI ran with `now-1h` inside it) was made deterministic by
|
||||||
clearing the test account's away window.
|
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)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
|
- **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
|
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
|
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
|
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/<version>/` 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
|
the runtime contract: a new `.dawg` appears in it and is loaded with
|
||||||
`dawg.Load`.
|
`dawg.Load`.
|
||||||
- **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a
|
- **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a
|
||||||
|
|||||||
+12
-2
@@ -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
|
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 `00007` (`accounts.notifications_in_app_only`, default true).
|
||||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
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
|
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
|
||||||
sibling [`../pkg`](../pkg) module.
|
**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/<version>/`
|
||||||
|
(`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
|
## 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/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
|
||||||
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
|
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/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)
|
## 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_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
| `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
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/config"
|
"scrabble/backend/internal/config"
|
||||||
|
"scrabble/backend/internal/connector"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/lobby"
|
"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")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("load dictionaries: %w", err)
|
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("dir", cfg.Game.DictDir),
|
||||||
zap.String("version", cfg.Game.DictVersion))
|
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())
|
sessions := session.NewService(session.NewStore(db), session.NewCache())
|
||||||
if err := sessions.Warm(ctx); err != nil {
|
if err := sessions.Warm(ctx); err != nil {
|
||||||
return fmt.Errorf("warm session cache: %w", err)
|
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,
|
Matchmaker: matchmaker,
|
||||||
Invitations: invitations,
|
Invitations: invitations,
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
|
Registry: registry,
|
||||||
|
DictDir: cfg.Game.DictDir,
|
||||||
|
Connector: conn,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ type Account struct {
|
|||||||
UpdatedAt time.Time
|
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.
|
// Store is the Postgres-backed query surface for accounts and identities.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -187,6 +197,54 @@ func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kin
|
|||||||
return row.ExternalID, nil
|
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,
|
// findByIdentity joins identities to accounts and returns the matching account,
|
||||||
// or ErrNotFound.
|
// or ErrNotFound.
|
||||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "layout" -}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>{{.Title}} · Scrabble admin</title>
|
||||||
|
<link rel="stylesheet" href="/_gm/assets/console.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="brand">Scrabble · admin</span>
|
||||||
|
<nav class="mainnav">
|
||||||
|
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
||||||
|
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||||
|
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||||
|
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||||
|
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||||
|
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Broadcast</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel"><h2>Post to the game channel</h2>
|
||||||
|
{{if .ConnectorEnabled}}
|
||||||
|
<form class="form col" method="post" action="/_gm/broadcast">
|
||||||
|
<label>Message <textarea name="text" required></textarea></label>
|
||||||
|
<div><button type="submit">Post to channel</button></div>
|
||||||
|
</form>
|
||||||
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||||
|
</section>
|
||||||
|
<p class="note">To message a single user, open their <a href="/_gm/users">user page</a>.</p>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<h1>Complaint: {{.Word}}</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/complaints">« complaints</a></nav>
|
||||||
|
<section class="panel"><h2>Details</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>Word</b> <code>{{.Word}}</code></li>
|
||||||
|
<li><b>Variant</b> {{.Variant}}</li>
|
||||||
|
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||||
|
<li><b>Lookup at filing</b> {{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</li>
|
||||||
|
<li><b>Filer note</b> {{if .Note}}{{.Note}}{{else}}<span class="note">none</span>{{end}}</li>
|
||||||
|
<li><b>Game</b> <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a></li>
|
||||||
|
<li><b>Filed</b> {{.CreatedAt}}</li>
|
||||||
|
<li><b>Status</b> {{.Status}}</li>
|
||||||
|
{{if .Resolved}}<li><b>Disposition</b> {{.Disposition}}</li><li><b>Resolution note</b> {{.ResolutionNote}}</li><li><b>Resolved</b> {{.ResolvedAt}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>{{if .Resolved}}Re-resolve{{else}}Resolve{{end}}</h2>
|
||||||
|
<form class="form col" method="post" action="/_gm/complaints/{{.ID}}/resolve">
|
||||||
|
<label>Disposition
|
||||||
|
<select name="disposition">
|
||||||
|
<option value="reject">reject — dictionary is correct</option>
|
||||||
|
<option value="accept_add">accept — add word to the dictionary</option>
|
||||||
|
<option value="accept_remove">accept — remove word from the dictionary</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Note <textarea name="note"></textarea></label>
|
||||||
|
<div><button type="submit">Resolve</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Complaints</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/_gm/complaints?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
|
||||||
|
<a href="/_gm/complaints?status=resolved"{{if eq .Status "resolved"}} class="active"{{end}}>resolved</a> ·
|
||||||
|
<a href="/_gm/complaints"{{if eq .Status ""}} class="active"{{end}}>all</a>
|
||||||
|
</nav>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Word</th><th>Variant</th><th>Was valid</th><th>Status</th><th>Disposition</th><th>Filed</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/complaints/{{.ID}}">{{.Word}}</a></td>
|
||||||
|
<td>{{.Variant}}</td>
|
||||||
|
<td>{{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</td>
|
||||||
|
<td>{{.Status}}</td>
|
||||||
|
<td>{{.Disposition}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="6"><span class="note">no complaints</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .Pager.HasPrev}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
|
{{if .Pager.HasNext}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p class="lede">Operator console for users, games, complaints and dictionaries.</p>
|
||||||
|
{{with .Data}}
|
||||||
|
<div class="cards">
|
||||||
|
<a class="card" href="/_gm/users"><h2>Users</h2><p class="bignum">{{.Accounts}}</p></a>
|
||||||
|
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
|
||||||
|
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
|
||||||
|
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
|
||||||
|
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
|
||||||
|
</div>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Dictionaries</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Variant</th><th>Latest</th><th>Resident versions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Variants}}
|
||||||
|
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Dictionary</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel"><h2>Resident versions</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Variants}}
|
||||||
|
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Hot-reload a version</h2>
|
||||||
|
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.</p>
|
||||||
|
<form class="form" method="post" action="/_gm/dictionary/reload">
|
||||||
|
<label>Version <input type="text" name="version" placeholder="v2" required></label>
|
||||||
|
<div><button type="submit">Reload</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Pending dictionary changes</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Variant</th><th>Action</th><th>Word</th><th>Resolved</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Changes}}
|
||||||
|
<tr><td>{{.Variant}}</td><td>{{.Action}}</td><td><code>{{.Word}}</code></td><td>{{.ResolvedAt}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="4"><span class="note">no pending changes</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||||
|
<label>Mark applied for variant
|
||||||
|
<select name="variant">
|
||||||
|
<option value="english">english</option>
|
||||||
|
<option value="russian_scrabble">russian_scrabble</option>
|
||||||
|
<option value="erudit">erudit</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>In version <input type="text" name="version" placeholder="v2" required></label>
|
||||||
|
<div><button type="submit">Mark applied</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<h1>Game {{.ID}}</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/games">« games</a></nav>
|
||||||
|
<section class="panel"><h2>Summary</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>Variant</b> {{.Variant}}</li>
|
||||||
|
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||||
|
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||||
|
<li><b>Players</b> {{.Players}}</li>
|
||||||
|
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||||
|
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||||
|
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||||
|
<li><b>Updated</b> {{.UpdatedAt}}</li>
|
||||||
|
{{if .FinishedAt}}<li><b>Finished</b> {{.FinishedAt}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Seats</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Seats}}
|
||||||
|
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Games</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
|
||||||
|
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
|
||||||
|
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||||
|
</nav>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .Pager.HasPrev}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
|
{{if .Pager.HasNext}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<h1>{{.Heading}}</h1>
|
||||||
|
<p>{{.Body}}</p>
|
||||||
|
<p><a href="{{.Back}}">« back</a></p>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<h1>{{.DisplayName}}</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/users">« users</a></nav>
|
||||||
|
<div class="cards">
|
||||||
|
<section class="panel"><h2>Account</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>ID</b> {{.ID}}</li>
|
||||||
|
<li><b>Language</b> {{.Language}}</li>
|
||||||
|
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||||
|
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||||
|
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||||
|
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||||
|
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Statistics</h2>
|
||||||
|
{{if .HasStats}}
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||||
|
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||||
|
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
||||||
|
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
||||||
|
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||||
|
</ul>
|
||||||
|
{{else}}<p class="note">no statistics</p>{{end}}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<section class="panel"><h2>Identities</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Identities}}
|
||||||
|
<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{if .Confirmed}}<span class="ok">yes</span>{{else}}<span class="warn">no</span>{{end}}</td><td>{{.CreatedAt}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{if .TelegramID}}
|
||||||
|
<section class="panel"><h2>Send Telegram message</h2>
|
||||||
|
{{if .ConnectorEnabled}}
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||||
|
<label>Message <textarea name="text" required></textarea></label>
|
||||||
|
<div><button type="submit">Send to user</button></div>
|
||||||
|
</form>
|
||||||
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
<section class="panel"><h2>Games</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Games}}
|
||||||
|
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Users</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||||
|
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}</td>
|
||||||
|
<td>{{.Kind}}</td>
|
||||||
|
<td>{{.Language}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5"><span class="note">no users</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
|
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ type Config struct {
|
|||||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||||
// selects the development log mailer (the code is logged, not sent).
|
// selects the development log mailer (the code is logged, not sent).
|
||||||
SMTP account.SMTPConfig
|
SMTP account.SMTPConfig
|
||||||
|
// 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.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
@@ -103,15 +107,16 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := Config{
|
c := Config{
|
||||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||||
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
||||||
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
||||||
Postgres: pg,
|
Postgres: pg,
|
||||||
Telemetry: tel,
|
Telemetry: tel,
|
||||||
Game: gm,
|
Game: gm,
|
||||||
Lobby: lb,
|
Lobby: lb,
|
||||||
Robot: rb,
|
Robot: rb,
|
||||||
SMTP: smtp,
|
SMTP: smtp,
|
||||||
|
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -63,6 +64,36 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
|
|||||||
return r, nil
|
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/<V>/
|
||||||
|
// 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
|
// 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
|
// registers it under version. Reloading the same (variant, version) replaces the
|
||||||
// previous entry, closing its finder. The most recently loaded version of a
|
// 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
|
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
|
// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant
|
||||||
// when the variant is absent and ErrUnknownVersion when only the version is.
|
// when the variant is absent and ErrUnknownVersion when only the version is.
|
||||||
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
|
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// turn, spending one hint from their per-game allowance and then their profile
|
||||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
// 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)
|
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.
|
// History returns a game's full, dictionary-independent move journal.
|
||||||
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
||||||
g, err := svc.store.GetGame(ctx, gameID)
|
g, err := svc.store.GetGame(ctx, gameID)
|
||||||
@@ -770,6 +849,29 @@ func normalizeWord(word string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(word))
|
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
|
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
|
||||||
// system source fails.
|
// system source fails.
|
||||||
func randomSeed() int64 {
|
func randomSeed() int64 {
|
||||||
|
|||||||
+190
-10
@@ -197,6 +197,53 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
|||||||
return out, nil
|
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.
|
// GetJournal loads the ordered, decoded move journal for a game.
|
||||||
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
|
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
|
||||||
stmt := postgres.SELECT(table.GameMoves.AllColumns).
|
stmt := postgres.SELECT(table.GameMoves.AllColumns).
|
||||||
@@ -384,6 +431,122 @@ func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, erro
|
|||||||
return projectComplaint(row)
|
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
|
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
|
||||||
// filters them against the per-move deadline and the player's away window.
|
// filters them against the per-move deadline and the player's away window.
|
||||||
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
||||||
@@ -523,19 +686,36 @@ func projectComplaint(row model.Complaints) (Complaint, error) {
|
|||||||
return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err)
|
return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err)
|
||||||
}
|
}
|
||||||
return Complaint{
|
return Complaint{
|
||||||
ID: row.ComplaintID,
|
ID: row.ComplaintID,
|
||||||
ComplainantID: row.ComplainantID,
|
ComplainantID: row.ComplainantID,
|
||||||
GameID: row.GameID,
|
GameID: row.GameID,
|
||||||
Variant: variant,
|
Variant: variant,
|
||||||
DictVersion: row.DictVersion,
|
DictVersion: row.DictVersion,
|
||||||
Word: row.Word,
|
Word: row.Word,
|
||||||
WasValid: row.WasValid,
|
WasValid: row.WasValid,
|
||||||
Note: row.Note,
|
Note: row.Note,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
|
Disposition: row.Disposition,
|
||||||
|
ResolutionNote: row.ResolutionNote,
|
||||||
|
ResolvedAt: row.ResolvedAt,
|
||||||
|
AppliedInVersion: row.AppliedInVersion,
|
||||||
}, nil
|
}, 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.
|
// 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 {
|
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
|||||||
@@ -15,9 +15,23 @@ const (
|
|||||||
StatusFinished = "finished"
|
StatusFinished = "finished"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ComplaintStatus values; Stage 10 owns the resolution lifecycle, Stage 3 only
|
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3)
|
||||||
// ever writes StatusComplaintOpen.
|
// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a
|
||||||
const StatusComplaintOpen = "open"
|
// 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,
|
// Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay,
|
||||||
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
|
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
|
||||||
@@ -179,7 +193,9 @@ type RobotTurn struct {
|
|||||||
Seed int64
|
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 {
|
type Complaint struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
ComplainantID uuid.UUID
|
ComplainantID uuid.UUID
|
||||||
@@ -191,4 +207,24 @@ type Complaint struct {
|
|||||||
Note string
|
Note string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt time.Time
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -13,14 +13,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Complaints struct {
|
type Complaints struct {
|
||||||
ComplaintID uuid.UUID `sql:"primary_key"`
|
ComplaintID uuid.UUID `sql:"primary_key"`
|
||||||
ComplainantID uuid.UUID
|
ComplainantID uuid.UUID
|
||||||
GameID uuid.UUID
|
GameID uuid.UUID
|
||||||
Variant string
|
Variant string
|
||||||
DictVersion string
|
DictVersion string
|
||||||
Word string
|
Word string
|
||||||
WasValid bool
|
WasValid bool
|
||||||
Note string
|
Note string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
Disposition string
|
||||||
|
ResolutionNote string
|
||||||
|
ResolvedAt *time.Time
|
||||||
|
AppliedInVersion string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,20 @@ type complaintsTable struct {
|
|||||||
postgres.Table
|
postgres.Table
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
ComplaintID postgres.ColumnString
|
ComplaintID postgres.ColumnString
|
||||||
ComplainantID postgres.ColumnString
|
ComplainantID postgres.ColumnString
|
||||||
GameID postgres.ColumnString
|
GameID postgres.ColumnString
|
||||||
Variant postgres.ColumnString
|
Variant postgres.ColumnString
|
||||||
DictVersion postgres.ColumnString
|
DictVersion postgres.ColumnString
|
||||||
Word postgres.ColumnString
|
Word postgres.ColumnString
|
||||||
WasValid postgres.ColumnBool
|
WasValid postgres.ColumnBool
|
||||||
Note postgres.ColumnString
|
Note postgres.ColumnString
|
||||||
Status postgres.ColumnString
|
Status postgres.ColumnString
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
Disposition postgres.ColumnString
|
||||||
|
ResolutionNote postgres.ColumnString
|
||||||
|
ResolvedAt postgres.ColumnTimestampz
|
||||||
|
AppliedInVersion postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -68,35 +72,43 @@ func newComplaintsTable(schemaName, tableName, alias string) *ComplaintsTable {
|
|||||||
|
|
||||||
func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable {
|
func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable {
|
||||||
var (
|
var (
|
||||||
ComplaintIDColumn = postgres.StringColumn("complaint_id")
|
ComplaintIDColumn = postgres.StringColumn("complaint_id")
|
||||||
ComplainantIDColumn = postgres.StringColumn("complainant_id")
|
ComplainantIDColumn = postgres.StringColumn("complainant_id")
|
||||||
GameIDColumn = postgres.StringColumn("game_id")
|
GameIDColumn = postgres.StringColumn("game_id")
|
||||||
VariantColumn = postgres.StringColumn("variant")
|
VariantColumn = postgres.StringColumn("variant")
|
||||||
DictVersionColumn = postgres.StringColumn("dict_version")
|
DictVersionColumn = postgres.StringColumn("dict_version")
|
||||||
WordColumn = postgres.StringColumn("word")
|
WordColumn = postgres.StringColumn("word")
|
||||||
WasValidColumn = postgres.BoolColumn("was_valid")
|
WasValidColumn = postgres.BoolColumn("was_valid")
|
||||||
NoteColumn = postgres.StringColumn("note")
|
NoteColumn = postgres.StringColumn("note")
|
||||||
StatusColumn = postgres.StringColumn("status")
|
StatusColumn = postgres.StringColumn("status")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
|
DispositionColumn = postgres.StringColumn("disposition")
|
||||||
mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
|
ResolutionNoteColumn = postgres.StringColumn("resolution_note")
|
||||||
defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn}
|
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{
|
return complaintsTable{
|
||||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
ComplaintID: ComplaintIDColumn,
|
ComplaintID: ComplaintIDColumn,
|
||||||
ComplainantID: ComplainantIDColumn,
|
ComplainantID: ComplainantIDColumn,
|
||||||
GameID: GameIDColumn,
|
GameID: GameIDColumn,
|
||||||
Variant: VariantColumn,
|
Variant: VariantColumn,
|
||||||
DictVersion: DictVersionColumn,
|
DictVersion: DictVersionColumn,
|
||||||
Word: WordColumn,
|
Word: WordColumn,
|
||||||
WasValid: WasValidColumn,
|
WasValid: WasValidColumn,
|
||||||
Note: NoteColumn,
|
Note: NoteColumn,
|
||||||
Status: StatusColumn,
|
Status: StatusColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
|
Disposition: DispositionColumn,
|
||||||
|
ResolutionNote: ResolutionNoteColumn,
|
||||||
|
ResolvedAt: ResolvedAtColumn,
|
||||||
|
AppliedInVersion: AppliedInVersionColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -87,7 +87,6 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/blocks", s.handleBlock)
|
u.POST("/blocks", s.handleBlock)
|
||||||
u.DELETE("/blocks/:id", s.handleUnblock)
|
u.DELETE("/blocks/:id", s.handleUnblock)
|
||||||
}
|
}
|
||||||
s.admin.GET("/ping", s.handleAdminPing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// userID returns the authenticated account id stored by RequireUserID. The user
|
// userID returns the authenticated account id stored by RequireUserID. The user
|
||||||
|
|||||||
@@ -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"})
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -43,13 +43,6 @@ func do(t *testing.T, s *Server, method, path, body string, headers map[string]s
|
|||||||
return rec
|
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) {
|
func TestProfileRequiresUserID(t *testing.T) {
|
||||||
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil)
|
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil)
|
||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -39,3 +40,40 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
|||||||
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
|
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
|
||||||
return id, ok
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/adminconsole"
|
||||||
|
"scrabble/backend/internal/connector"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -55,6 +58,15 @@ type Deps struct {
|
|||||||
Matchmaker *lobby.Matchmaker
|
Matchmaker *lobby.Matchmaker
|
||||||
Invitations *lobby.InvitationService
|
Invitations *lobby.InvitationService
|
||||||
Emails *account.EmailService
|
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
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -73,11 +85,14 @@ type Server struct {
|
|||||||
matchmaker *lobby.Matchmaker
|
matchmaker *lobby.Matchmaker
|
||||||
invitations *lobby.InvitationService
|
invitations *lobby.InvitationService
|
||||||
emails *account.EmailService
|
emails *account.EmailService
|
||||||
|
registry *engine.Registry
|
||||||
|
dictDir string
|
||||||
|
connector *connector.Client
|
||||||
|
console *adminconsole.Renderer
|
||||||
|
|
||||||
public *gin.RouterGroup
|
public *gin.RouterGroup
|
||||||
user *gin.RouterGroup
|
user *gin.RouterGroup
|
||||||
internal *gin.RouterGroup
|
internal *gin.RouterGroup
|
||||||
admin *gin.RouterGroup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Server that will listen on addr. It installs the recovery and
|
// 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,
|
matchmaker: deps.Matchmaker,
|
||||||
invitations: deps.Invitations,
|
invitations: deps.Invitations,
|
||||||
emails: deps.Emails,
|
emails: deps.Emails,
|
||||||
|
registry: deps.Registry,
|
||||||
|
dictDir: deps.DictDir,
|
||||||
|
connector: deps.Connector,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
}
|
}
|
||||||
s.registerProbes(engine)
|
s.registerProbes(engine)
|
||||||
s.registerAPIGroups(engine)
|
s.registerAPIGroups(engine)
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
|
s.registerConsole(engine)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +172,6 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
|
|||||||
s.user = v1.Group("/user")
|
s.user = v1.Group("/user")
|
||||||
s.user.Use(RequireUserID())
|
s.user.Use(RequireUserID())
|
||||||
s.internal = v1.Group("/internal")
|
s.internal = v1.Group("/internal")
|
||||||
s.admin = v1.Group("/admin")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicGroup returns the unauthenticated public route group.
|
// 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.
|
// InternalGroup returns the gateway-facing internal route group.
|
||||||
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
|
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.
|
// Social returns the social domain service for the handlers added in Stage 6.
|
||||||
func (s *Server) Social() *social.Service { return s.social }
|
func (s *Server) Social() *social.Service { return s.social }
|
||||||
|
|
||||||
|
|||||||
+23
-14
@@ -14,8 +14,9 @@ Three executables plus per-platform side-services:
|
|||||||
- **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs
|
- **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs
|
||||||
anti-abuse (rate limiting), authenticates the player against the originating
|
anti-abuse (rate limiting), authenticates the player against the originating
|
||||||
platform (or an email/guest session), resolves the internal `user_id`, and
|
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
|
forwards authenticated traffic to `backend` with an `X-User-ID` header. Serves the
|
||||||
admin surface behind HTTP Basic Auth. Bridges live events from `backend` to 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
|
client. The shared wire contracts (the push proto and the FlatBuffers edge
|
||||||
payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`.
|
payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`.
|
||||||
- **`backend`** — internal-only service that owns every domain concern:
|
- **`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
|
`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
|
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
|
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**
|
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
|
(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
|
initData validation is Telegram-specific. It runs in its own container, egressing to
|
||||||
@@ -62,7 +63,7 @@ flowchart LR
|
|||||||
Backend -- pgx --> Postgres[(Postgres)]
|
Backend -- pgx --> Postgres[(Postgres)]
|
||||||
Backend -. embeds .- Solver[[scrabble-solver library]]
|
Backend -. embeds .- Solver[[scrabble-solver library]]
|
||||||
Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector]
|
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))
|
Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram))
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -166,11 +167,15 @@ Key points:
|
|||||||
word-check tool through `Registry.Lookup`.
|
word-check tool through `Registry.Lookup`.
|
||||||
- **Dictionary versioning — pin per game.** A game records the `dict_version` it
|
- **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
|
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)*
|
versions may be resident at once. The boot version loads from the flat
|
||||||
registers a new version through `Registry.Load`; delivery is the DAWG file in
|
`BACKEND_DICT_DIR`; the admin console **hot-reloads** a new version from a
|
||||||
the image / a volume mounted at the dictionary directory. (A future split of
|
per-version subdirectory `BACKEND_DICT_DIR/<version>/` through
|
||||||
the solver into engine + dictionary generator with versioned artifacts is
|
`Registry.LoadAvailable` (only the variants whose DAWG is present there), and a
|
||||||
recorded in [`../PLAN.md`](../PLAN.md) TODO-2.)
|
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),
|
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
||||||
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
||||||
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
||||||
@@ -231,8 +236,11 @@ Key points:
|
|||||||
"no options" rather than "no hints left".
|
"no options" rather than "no hints left".
|
||||||
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
|
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
|
||||||
dictionary; each result offers a **complaint** (complainant, game, variant,
|
dictionary; each result offers a **complaint** (complainant, game, variant,
|
||||||
dict_version, word, the disputed result, an optional note) that lands in an
|
dict_version, word, the disputed result, an optional note) that lands in the admin
|
||||||
admin review queue *(admin side planned, Stage 10)*.
|
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
|
## 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 minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
| 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) |
|
| 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
|
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
|
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
|
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
|
that path redirects to the root), the gateway public surface and the **admin console
|
||||||
share one host that terminates TLS. The **Telegram connector** runs as a separate
|
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
|
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
|
VPN sidecar, answering only internal gRPC. MVP runs one `gateway`, one `backend`, one
|
||||||
Postgres, plus the connector. The connector's Docker/compose ships now
|
Postgres, plus the connector. The connector's Docker/compose ships now
|
||||||
|
|||||||
+12
-2
@@ -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).
|
best play, which already includes every word it formed plus the all-tiles bonus).
|
||||||
|
|
||||||
### Administration *(Stage 10)*
|
### Administration *(Stage 10)*
|
||||||
Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary
|
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
|
||||||
versions, and inspects users/games.
|
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/<version>/`, 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.
|
||||||
|
|||||||
+11
-2
@@ -113,5 +113,14 @@ push доставляется через платформу.
|
|||||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
ход, уже включающий все образованные им слова и бонус за все фишки).
|
||||||
|
|
||||||
### Администрирование *(Stage 10)*
|
### Администрирование *(Stage 10)*
|
||||||
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
|
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
|
||||||
словаря, смотрит пользователей/игры.
|
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
|
||||||
|
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
|
||||||
|
identity, их игры) и **игры** (сводка + места), разбирать **очередь жалоб на слова** —
|
||||||
|
закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**:
|
||||||
|
резидентные версии по вариантам, **горячая перезагрузка** новой версии из
|
||||||
|
`BACKEND_DICT_DIR/<version>/` и **список ожидающих правок**, выведенный из принятых
|
||||||
|
жалоб (он питает офлайн-пересборку и отмечается применённым после перезагрузки). Если
|
||||||
|
подключён Telegram-коннектор, оператор также может **написать пользователю** (по его
|
||||||
|
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
|
||||||
|
защищены проверкой same-origin; личность оператора не отслеживается.
|
||||||
|
|||||||
@@ -82,6 +82,17 @@ tests or touching CI.
|
|||||||
in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account
|
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,
|
operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
|
||||||
the profile-update away round-trip) and a `notify`-event constructor round-trip.
|
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
|
## Principles
|
||||||
|
|
||||||
|
|||||||
+7
-8
@@ -5,9 +5,9 @@ terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2
|
|||||||
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
|
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
|
||||||
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
||||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||||
client's in-app live channel. It also fronts the backend admin API behind HTTP
|
client's in-app live channel. It also serves the backend's admin console at `/_gm`
|
||||||
Basic-Auth. See [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10,
|
on its public listener behind HTTP Basic-Auth. See
|
||||||
§12.
|
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12.
|
||||||
|
|
||||||
## Package layout
|
## 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/push/ # live-event fan-out hub (per-user client streams)
|
||||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||||
internal/connectsrv/ # the Connect Gateway service over h2c
|
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
|
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 |
|
| Variable | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener |
|
| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener (also serves the admin console at `/_gm`) |
|
||||||
| `GATEWAY_ADMIN_ADDR` | `:8082` | admin proxy listener (enabled only with creds) |
|
|
||||||
| `GATEWAY_LOG_LEVEL` | `info` | zap level |
|
| `GATEWAY_LOG_LEVEL` | `info` | zap level |
|
||||||
| `GATEWAY_BACKEND_HTTP_URL` | `http://localhost:8080` | backend REST base URL |
|
| `GATEWAY_BACKEND_HTTP_URL` | `http://localhost:8080` | backend REST base URL |
|
||||||
| `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address |
|
| `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address |
|
||||||
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
||||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin proxy |
|
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
||||||
| `GATEWAY_TELEGRAM_BOT_TOKEN` | unset | enable the Telegram auth path |
|
| `GATEWAY_CONNECTOR_ADDR` | unset | Telegram connector gRPC address (enables initData validation + out-of-app push) |
|
||||||
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
|
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
|
||||||
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
|
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
|
||||||
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
|
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
|
||||||
|
|||||||
+25
-21
@@ -2,8 +2,8 @@
|
|||||||
// the client's Connect-RPC/FlatBuffers traffic over h2c, validates platform /
|
// the client's Connect-RPC/FlatBuffers traffic over h2c, validates platform /
|
||||||
// email / guest credentials and mints opaque sessions, rate-limits, injects
|
// 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
|
// 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
|
// gRPC push stream to each client's in-app live channel. It also serves the
|
||||||
// backend admin API behind HTTP Basic-Auth.
|
// backend's admin console at /_gm on the public listener behind HTTP Basic-Auth.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -57,8 +57,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// run wires the gateway dependencies and serves the public (and optional admin)
|
// run wires the gateway dependencies and serves the public listener (which also
|
||||||
// listeners until the context is cancelled.
|
// fronts the admin console at /_gm) until the context is cancelled.
|
||||||
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
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)")
|
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)
|
registry := transcode.NewRegistry(backend, validator)
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
RateLimit: cfg.RateLimit,
|
RateLimit: cfg.RateLimit,
|
||||||
Heartbeat: cfg.PushHeartbeatInterval,
|
Heartbeat: cfg.PushHeartbeatInterval,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
AdminProxy: adminProxy,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
// 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()}
|
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()}
|
||||||
servers := []*namedServer{{name: "public", srv: public}}
|
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",
|
logger.Info("gateway starting",
|
||||||
zap.String("http_addr", cfg.HTTPAddr),
|
zap.String("http_addr", cfg.HTTPAddr),
|
||||||
zap.String("backend_http", cfg.BackendHTTPURL),
|
zap.String("backend_http", cfg.BackendHTTPURL),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// Package admin is the gateway's admin surface: HTTP Basic-Auth in front of a
|
// Package admin is the gateway's admin edge: HTTP Basic-Auth in front of a reverse
|
||||||
// reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway
|
// proxy that forwards the operator's browser to the backend's server-rendered admin
|
||||||
// validates the operator credential and forwards authenticated requests to
|
// console under /_gm. The proxy is mounted at /_gm/ on the gateway's public listener
|
||||||
// backend /api/v1/admin/*; the backend trusts the gateway on this segment. The
|
// (below the h2c wrap, see internal/connectsrv) and forwards verbatim — an inbound
|
||||||
// admin API itself is filled in Stage 10.
|
// /_gm/<rest> reaches <backendURL>/_gm/<rest>, 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
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,17 +14,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"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
|
// NewProxy returns a handler that checks Basic-Auth against user/password and
|
||||||
// reverse-proxies the request to the backend admin API, mapping an inbound
|
// reverse-proxies the request verbatim to the backend: the inbound path is
|
||||||
// /admin/<rest> path to <backendURL>/api/v1/admin/<rest>.
|
// preserved, so /_gm/<rest> reaches <backendURL>/_gm/<rest>. It is mounted at /_gm/
|
||||||
|
// on the gateway's public listener.
|
||||||
func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, error) {
|
func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, error) {
|
||||||
target, err := url.Parse(backendURL)
|
target, err := url.Parse(backendURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,10 +32,8 @@ func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler,
|
|||||||
}
|
}
|
||||||
proxy := &httputil.ReverseProxy{
|
proxy := &httputil.ReverseProxy{
|
||||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||||
pr.SetURL(target)
|
pr.SetURL(target) // backend scheme+host; the inbound /_gm path is preserved
|
||||||
rel := strings.TrimPrefix(pr.In.URL.Path, "/admin")
|
pr.Out.Host = pr.In.Host // keep the public Host for the backend same-origin check
|
||||||
pr.Out.URL.Path = backendAdminPrefix + rel
|
|
||||||
pr.Out.Host = pr.In.Host
|
|
||||||
},
|
},
|
||||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
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))
|
log.Warn("admin proxy upstream error", zap.String("path", r.URL.Path), zap.Error(err))
|
||||||
|
|||||||
@@ -9,27 +9,28 @@ import (
|
|||||||
"scrabble/gateway/internal/admin"
|
"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()
|
t.Helper()
|
||||||
|
var path string
|
||||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/api/v1/admin/ping" {
|
path = r.URL.Path
|
||||||
t.Errorf("backend path = %q, want /api/v1/admin/ping", r.URL.Path)
|
_, _ = w.Write([]byte("console"))
|
||||||
}
|
|
||||||
_, _ = w.Write([]byte("pong"))
|
|
||||||
}))
|
}))
|
||||||
proxy, err := admin.NewProxy(backend.URL, "ops", "secret", nil)
|
proxy, err := admin.NewProxy(backend.URL, "ops", "secret", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new proxy: %v", err)
|
t.Fatalf("new proxy: %v", err)
|
||||||
}
|
}
|
||||||
front := httptest.NewServer(proxy)
|
front = httptest.NewServer(proxy)
|
||||||
return front, func() { front.Close(); backend.Close() }
|
return front, &path, func() { front.Close(); backend.Close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRejectsMissingCredentials(t *testing.T) {
|
func TestAdminRejectsMissingCredentials(t *testing.T) {
|
||||||
front, cleanup := newAdmin(t)
|
front, _, cleanup := newAdmin(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
resp, err := http.Get(front.URL + "/admin/ping")
|
resp, err := http.Get(front.URL + "/_gm/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -37,13 +38,16 @@ func TestAdminRejectsMissingCredentials(t *testing.T) {
|
|||||||
if resp.StatusCode != http.StatusUnauthorized {
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
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) {
|
func TestAdminProxiesVerbatimWithCredentials(t *testing.T) {
|
||||||
front, cleanup := newAdmin(t)
|
front, gotPath, cleanup := newAdmin(t)
|
||||||
defer cleanup()
|
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")
|
req.SetBasicAuth("ops", "secret")
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,16 +55,19 @@ func TestAdminProxiesWithCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if resp.StatusCode != http.StatusOK || string(body) != "pong" {
|
if resp.StatusCode != http.StatusOK || string(body) != "console" {
|
||||||
t.Fatalf("status = %d body = %q, want 200 pong", resp.StatusCode, body)
|
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) {
|
func TestAdminRejectsWrongPassword(t *testing.T) {
|
||||||
front, cleanup := newAdmin(t)
|
front, _, cleanup := newAdmin(t)
|
||||||
defer cleanup()
|
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")
|
req.SetBasicAuth("ops", "wrong")
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ import (
|
|||||||
|
|
||||||
// Config holds the gateway's runtime configuration.
|
// Config holds the gateway's runtime configuration.
|
||||||
type Config struct {
|
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
|
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 is the zap log level: "debug", "info", "warn" or "error".
|
||||||
LogLevel string
|
LogLevel string
|
||||||
// BackendHTTPURL is the base URL of the backend REST API (gateway -> backend).
|
// 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.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
const (
|
const (
|
||||||
defaultHTTPAddr = ":8081"
|
defaultHTTPAddr = ":8081"
|
||||||
defaultAdminAddr = ":8082"
|
|
||||||
defaultLogLevel = "info"
|
defaultLogLevel = "info"
|
||||||
defaultBackendHTTPURL = "http://localhost:8080"
|
defaultBackendHTTPURL = "http://localhost:8080"
|
||||||
defaultBackendGRPCAddr = "localhost:9090"
|
defaultBackendGRPCAddr = "localhost:9090"
|
||||||
@@ -85,7 +82,6 @@ func Load() (Config, error) {
|
|||||||
var err error
|
var err error
|
||||||
c := Config{
|
c := Config{
|
||||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||||
AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr),
|
|
||||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||||
@@ -113,10 +109,10 @@ func Load() (Config, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminEnabled reports whether the admin proxy should be served (an address and
|
// AdminEnabled reports whether the admin console proxy should be mounted (both
|
||||||
// both Basic-Auth credentials are configured).
|
// Basic-Auth credentials are configured).
|
||||||
func (c Config) AdminEnabled() bool {
|
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.
|
// validate reports whether the configuration values are acceptable.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,13 @@ const heartbeatKind = "heartbeat"
|
|||||||
|
|
||||||
// Server implements edgev1connect.GatewayHandler.
|
// Server implements edgev1connect.GatewayHandler.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
registry *transcode.Registry
|
registry *transcode.Registry
|
||||||
sessions *session.Cache
|
sessions *session.Cache
|
||||||
limiter *ratelimit.Limiter
|
limiter *ratelimit.Limiter
|
||||||
hub *push.Hub
|
hub *push.Hub
|
||||||
heartbeat time.Duration
|
heartbeat time.Duration
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
|
adminProxy http.Handler
|
||||||
|
|
||||||
publicPolicy ratelimit.Policy
|
publicPolicy ratelimit.Policy
|
||||||
userPolicy ratelimit.Policy
|
userPolicy ratelimit.Policy
|
||||||
@@ -46,13 +47,14 @@ type Server struct {
|
|||||||
|
|
||||||
// Deps carries the Server's dependencies.
|
// Deps carries the Server's dependencies.
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Registry *transcode.Registry
|
Registry *transcode.Registry
|
||||||
Sessions *session.Cache
|
Sessions *session.Cache
|
||||||
Limiter *ratelimit.Limiter
|
Limiter *ratelimit.Limiter
|
||||||
Hub *push.Hub
|
Hub *push.Hub
|
||||||
RateLimit config.RateLimitConfig
|
RateLimit config.RateLimitConfig
|
||||||
Heartbeat time.Duration
|
Heartbeat time.Duration
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
|
AdminProxy http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs the edge service.
|
// NewServer constructs the edge service.
|
||||||
@@ -68,6 +70,7 @@ func NewServer(d Deps) *Server {
|
|||||||
hub: d.Hub,
|
hub: d.Hub,
|
||||||
heartbeat: d.Heartbeat,
|
heartbeat: d.Heartbeat,
|
||||||
log: log,
|
log: log,
|
||||||
|
adminProxy: d.AdminProxy,
|
||||||
publicPolicy: ratelimit.PerMinute(d.RateLimit.PublicPerMinute, d.RateLimit.PublicBurst),
|
publicPolicy: ratelimit.PerMinute(d.RateLimit.PublicPerMinute, d.RateLimit.PublicBurst),
|
||||||
userPolicy: ratelimit.PerMinute(d.RateLimit.UserPerMinute, d.RateLimit.UserBurst),
|
userPolicy: ratelimit.PerMinute(d.RateLimit.UserPerMinute, d.RateLimit.UserBurst),
|
||||||
emailPolicy: ratelimit.Per(d.RateLimit.EmailPer10Min, 10*time.Minute, d.RateLimit.EmailBurst),
|
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()
|
mux := http.NewServeMux()
|
||||||
path, h := edgev1connect.NewGatewayHandler(s)
|
path, h := edgev1connect.NewGatewayHandler(s)
|
||||||
mux.Handle(path, h)
|
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{})
|
return h2c.NewHandler(mux, &http2.Server{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user