diff --git a/PRERELEASE.md b/PRERELEASE.md index 5d7f542..e668bcf 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -27,6 +27,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** | | OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** | +| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/Dockerfile b/backend/Dockerfile index 9d06dae..ae17cc3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -37,7 +37,17 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/ba # --- runtime ----------------------------------------------------------------- FROM gcr.io/distroless/static-debian12:nonroot +# Re-declare the build arg in this stage so it labels the seed dictionary. One +# DICT_VERSION drives both the artifact the dawg stage downloads and the version +# label the binary pins, so the resident version equals the release tag. +ARG DICT_VERSION=v1.0.0 COPY --from=build /out/backend /usr/local/bin/backend -COPY --from=dawg /dawg /opt/dawg +# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume +# mounted at /opt/dawg inherits this ownership on first use, so the admin console +# can write new version subdirectories at runtime. The volume preserves uploaded +# versions across deploys and, once seeded, is not re-seeded — so after bootstrap +# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5). +COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg ENV BACKEND_DICT_DIR=/opt/dawg +ENV BACKEND_DICT_VERSION=${DICT_VERSION} ENTRYPOINT ["/usr/local/bin/backend"] diff --git a/backend/README.md b/backend/README.md index ef038c2..ba62f5d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -81,8 +81,10 @@ with no identity, excluded from statistics. The server-rendered the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the **complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/ `resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change -pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR//` -(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a +pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz` release +archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` + +`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the +`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language` holds the language tag of the bot a Telegram diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 5d559d6..81d2cb2 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -135,6 +135,14 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { accounts := account.NewStore(db) accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account")) games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger) + // Reconcile the persisted active dictionary version with the registry: a + // version activated through the admin console (and written to the dictionary + // volume) is adopted again after a restart; otherwise the configured seed + // version is kept and persisted (docs/ARCHITECTURE.md §5). + if err := games.InitActiveVersion(ctx); err != nil { + return fmt.Errorf("init active dictionary version: %w", err) + } + logger.Info("active dictionary version", zap.String("version", games.ActiveVersion())) games.SetNotifier(hub) games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game")) go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval) diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index 96955f5..8d9d7b9 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -36,7 +36,8 @@ func TestRendererRendersEveryPage(t *testing.T) { {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"}, - {"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Hot-reload"}, + {"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"}, + {"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"}, {"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"}, {"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"}, } diff --git a/backend/internal/adminconsole/templates/pages/dictionary.gohtml b/backend/internal/adminconsole/templates/pages/dictionary.gohtml index fb637f2..c6041ef 100644 --- a/backend/internal/adminconsole/templates/pages/dictionary.gohtml +++ b/backend/internal/adminconsole/templates/pages/dictionary.gohtml @@ -1,21 +1,24 @@ {{define "content" -}}

Dictionary

{{with .Data}} +

Active version

+

New games pin {{.ActiveVersion}}. Games already in progress keep the version they started on.

+

Resident versions

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

Hot-reload a version

-

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

-
- -
+

Update dictionaries

+

Upload the release archive scrabble-dawg-vX.Y.Z.tar.gz downloaded from the scrabble-dictionary repository. The next step previews the added and removed words per variant; nothing is installed until you confirm.

+ + +

Pending dictionary changes

@@ -35,7 +38,7 @@ - +
diff --git a/backend/internal/adminconsole/templates/pages/dictionary_preview.gohtml b/backend/internal/adminconsole/templates/pages/dictionary_preview.gohtml new file mode 100644 index 0000000..242fcf1 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/dictionary_preview.gohtml @@ -0,0 +1,27 @@ +{{define "content" -}} +

Dictionary update — preview

+{{with .Data}} +
+

Comparing the uploaded archive against the active version {{.ActiveVersion}}. Review the changes below, then confirm to install and activate.

+
+ + +
+
+
+{{range .Variants}} +
+

{{.Variant}}

+

{{.AddedCount}} added, {{.RemovedCount}} removed. +{{if .LargeRemoval}}Large removal — double-check this is expected before updating.{{end}}

+

Added{{if .AddedTruncated}} (showing first {{len .AddedSample}}){{end}}

+

{{range .AddedSample}}{{.}} {{else}}none{{end}}

+{{if .AddedTruncated}}

… and more; the full list is not shown.

{{end}} +

Removed{{if .RemovedTruncated}} (showing first {{len .RemovedSample}}){{end}}

+

{{range .RemovedSample}}{{.}} {{else}}none{{end}}

+{{if .RemovedTruncated}}

… and more; the full list is not shown.

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

Cancel

+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 1018557..e59d46c 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -43,7 +43,10 @@ type DashboardView struct { ActiveGames int OpenComplaints int PendingChanges int - Variants []VariantVersions + // ActiveVersion is the dictionary version new games pin (the persisted active + // version), distinct from the per-variant resident versions. + ActiveVersion string + Variants []VariantVersions } // UsersView is the paginated account list. @@ -237,11 +240,13 @@ type ComplaintDetailView struct { Resolved bool } -// DictionaryView lists the resident versions per variant and the pending -// wordlist changes from accepted complaints. +// DictionaryView lists the resident versions per variant, the active version new +// games pin, and the pending wordlist changes from accepted complaints. type DictionaryView struct { - Variants []VariantVersions - Changes []DictChangeRow + // ActiveVersion is the dictionary version new games pin; the update form sets it. + ActiveVersion string + Variants []VariantVersions + Changes []DictChangeRow } // DictChangeRow is one pending wordlist edit. @@ -252,6 +257,32 @@ type DictChangeRow struct { ResolvedAt string } +// DictionaryPreviewView is the second step of a dictionary update: the version +// parsed from the uploaded archive (editable before confirming), the staging token +// that names the uploaded files on disk, the active version the diff is against, and +// the per-variant word diff. +type DictionaryPreviewView struct { + Version string + Token string + ActiveVersion string + Variants []VariantDiffRow +} + +// VariantDiffRow summarises one variant's word diff in the update preview. +// AddedCount/RemovedCount are the full totals; AddedSample/RemovedSample are the +// first words shown (capped); AddedTruncated/RemovedTruncated mark a capped list; +// LargeRemoval flags a removal large enough to warrant caution before confirming. +type VariantDiffRow struct { + Variant string + AddedCount int + RemovedCount int + AddedSample []string + RemovedSample []string + AddedTruncated bool + RemovedTruncated bool + LargeRemoval bool +} + // BroadcastView is the operator-broadcast form page. type BroadcastView struct { ConnectorEnabled bool diff --git a/backend/internal/dictadmin/dictadmin.go b/backend/internal/dictadmin/dictadmin.go new file mode 100644 index 0000000..752591c --- /dev/null +++ b/backend/internal/dictadmin/dictadmin.go @@ -0,0 +1,262 @@ +// Package dictadmin stages and installs scrabble-dictionary release archives for +// the admin console's online dictionary-update flow. It validates the release +// archive (scrabble-dawg-vX.Y.Z.tar.gz), extracts it safely into a per-version +// directory under BACKEND_DICT_DIR, and keeps a staging area for the two-step +// upload-then-confirm interaction. Extraction is hardened against the usual +// archive risks (path traversal, symlinks, decompression bombs). +package dictadmin + +import ( + "archive/tar" + "compress/gzip" + crand "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "time" + + "scrabble/backend/internal/engine" +) + +// MaxArchiveBytes bounds an uploaded release archive; the handler wraps the request +// body in an http.MaxBytesReader with this limit. The three compressed DAWGs are a +// few MB together, so 64 MiB is generous. +const MaxArchiveBytes int64 = 64 << 20 + +// stagingRoot is the dot-prefixed directory under the dictionary dir holding +// not-yet-installed uploads. The engine's boot scan skips dot-prefixed dirs, so a +// staged upload never becomes a resident version. +const stagingRoot = ".staging" + +// stagingTTL is how long an abandoned staged upload lingers before Stage sweeps it. +const stagingTTL = time.Hour + +// maxMemberBytes and maxEntries bound a single archive member and the number of +// entries, defeating decompression and entry-count bombs. They are variables so +// tests can lower them. maxMemberBytes is per file; a real DAWG is a few MB. +var ( + maxMemberBytes int64 = 32 << 20 + maxEntries = 64 +) + +var ( + nameRE = regexp.MustCompile(`^scrabble-dawg-(v[0-9]+\.[0-9]+\.[0-9]+)\.tar\.gz$`) + versionRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) + tokenRE = regexp.MustCompile(`^[0-9a-f]{32}$`) +) + +// ParseVersionFromName extracts the version from a release archive filename of the +// form scrabble-dawg-vX.Y.Z.tar.gz, or returns an error for any other shape. +func ParseVersionFromName(filename string) (string, error) { + m := nameRE.FindStringSubmatch(filename) + if m == nil { + return "", fmt.Errorf("dictadmin: %q is not a scrabble-dawg-vX.Y.Z.tar.gz archive", filename) + } + return m[1], nil +} + +// ValidVersion reports whether v is a vMAJOR.MINOR.PATCH version label. It guards +// the operator's manual override before the version is used as a directory name. +func ValidVersion(v string) bool { + return versionRE.MatchString(v) +} + +// Extract reads a gzip+tar release archive from r and writes the recognised +// dictionary files into destDir, returning the variants found in catalogue order. +// Only regular files whose base name is a known DAWG filename are written (their +// directory part is discarded, so a traversal entry cannot escape destDir); +// symlinks and other types are ignored, and oversize members or an excessive entry +// count are rejected. +func Extract(r io.Reader, destDir string) ([]engine.Variant, error) { + gz, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("dictadmin: open gzip: %w", err) + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(gz) + + byName := filenameToVariant() + seen := make(map[engine.Variant]bool) + entries := 0 + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("dictadmin: read archive: %w", err) + } + entries++ + if entries > maxEntries { + return nil, fmt.Errorf("dictadmin: archive has more than %d entries", maxEntries) + } + if hdr.Typeflag != tar.TypeReg { + continue + } + base := filepath.Base(filepath.Clean(hdr.Name)) + v, ok := byName[base] + if !ok { + continue + } + if seen[v] { + return nil, fmt.Errorf("dictadmin: duplicate %s in archive", base) + } + if hdr.Size > maxMemberBytes { + return nil, fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", base, maxMemberBytes) + } + if err := writeMember(filepath.Join(destDir, base), tr); err != nil { + return nil, err + } + seen[v] = true + } + + var found []engine.Variant + for _, v := range engine.Variants() { + if seen[v] { + found = append(found, v) + } + } + return found, nil +} + +// writeMember copies one archive member into path, bounding it at maxMemberBytes so +// a member whose declared size lies cannot exhaust the disk. +func writeMember(path string, tr *tar.Reader) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("dictadmin: create %s: %w", filepath.Base(path), err) + } + defer func() { _ = f.Close() }() + n, err := io.Copy(f, io.LimitReader(tr, maxMemberBytes+1)) + if err != nil { + return fmt.Errorf("dictadmin: write %s: %w", filepath.Base(path), err) + } + if n > maxMemberBytes { + return fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", filepath.Base(path), maxMemberBytes) + } + return nil +} + +// filenameToVariant inverts the engine's variant-to-filename map. +func filenameToVariant() map[string]engine.Variant { + files := engine.DictFiles() + out := make(map[string]engine.Variant, len(files)) + for v, name := range files { + out[name] = v + } + return out +} + +// Manager stages and installs release archives under a dictionary directory +// (BACKEND_DICT_DIR). +type Manager struct { + dir string +} + +// New constructs a Manager rooted at the dictionary directory dir. +func New(dir string) *Manager { + return &Manager{dir: dir} +} + +// Stage accepts an uploaded archive: it sweeps abandoned previews, allocates a +// staging directory under /.staging/, extracts the archive there and +// returns the token and the variants found. The staging directory is removed on an +// extraction error. +func (m *Manager) Stage(r io.Reader) (string, []engine.Variant, error) { + m.cleanStaging() + token, err := newToken() + if err != nil { + return "", nil, err + } + dest := filepath.Join(m.dir, stagingRoot, token) + if err := os.MkdirAll(dest, 0o755); err != nil { + return "", nil, fmt.Errorf("dictadmin: create staging dir: %w", err) + } + variants, err := Extract(r, dest) + if err != nil { + _ = os.RemoveAll(dest) + return "", nil, err + } + return token, variants, nil +} + +// StagedDir returns the directory of the staged upload named by token, after +// validating the token's shape and confirming the directory exists. +func (m *Manager) StagedDir(token string) (string, error) { + if !tokenRE.MatchString(token) { + return "", fmt.Errorf("dictadmin: invalid staging token") + } + dir := filepath.Join(m.dir, stagingRoot, token) + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return "", fmt.Errorf("dictadmin: staged upload not found") + } + return dir, nil +} + +// VersionExists reports whether the version directory / is present. +func (m *Manager) VersionExists(version string) bool { + info, err := os.Stat(filepath.Join(m.dir, version)) + return err == nil && info.IsDir() +} + +// Install promotes a staged upload into its version directory / by an +// atomic rename (same filesystem) and returns the destination path. It rejects an +// invalid version and refuses to overwrite an existing version: versions are +// immutable, protecting games pinned to that tag. +func (m *Manager) Install(token, version string) (string, error) { + if !ValidVersion(version) { + return "", fmt.Errorf("dictadmin: invalid version %q", version) + } + staged, err := m.StagedDir(token) + if err != nil { + return "", err + } + if m.VersionExists(version) { + return "", fmt.Errorf("dictadmin: version %s already exists", version) + } + dest := filepath.Join(m.dir, version) + if err := os.Rename(staged, dest); err != nil { + return "", fmt.Errorf("dictadmin: install %s: %w", version, err) + } + return dest, nil +} + +// Discard removes the staging directory named by token, best effort. It is called +// when a preview is rejected (an incomplete or unusable archive). +func (m *Manager) Discard(token string) { + if dir, err := m.StagedDir(token); err == nil { + _ = os.RemoveAll(dir) + } +} + +// cleanStaging removes staging directories older than stagingTTL, best effort. +func (m *Manager) cleanStaging() { + root := filepath.Join(m.dir, stagingRoot) + entries, err := os.ReadDir(root) + if err != nil { + return + } + cutoff := time.Now().Add(-stagingTTL) + for _, e := range entries { + if !e.IsDir() { + continue + } + if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) { + _ = os.RemoveAll(filepath.Join(root, e.Name())) + } + } +} + +// newToken returns a 32-hex-character random staging token. +func newToken() (string, error) { + var b [16]byte + if _, err := crand.Read(b[:]); err != nil { + return "", fmt.Errorf("dictadmin: generate token: %w", err) + } + return hex.EncodeToString(b[:]), nil +} diff --git a/backend/internal/dictadmin/dictadmin_test.go b/backend/internal/dictadmin/dictadmin_test.go new file mode 100644 index 0000000..571ea44 --- /dev/null +++ b/backend/internal/dictadmin/dictadmin_test.go @@ -0,0 +1,248 @@ +package dictadmin + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "scrabble/backend/internal/engine" +) + +// dawgNames are the three committed dictionary filenames a release archive holds. +func dawgNames() []string { + out := make([]string, 0, 3) + for _, name := range engine.DictFiles() { + out = append(out, name) + } + return out +} + +// fullArchive builds a gzip+tar archive holding all three dictionary files with +// dummy content, the shape of a real release archive. +func fullArchive(t *testing.T) []byte { + t.Helper() + files := map[string][]byte{} + for _, n := range dawgNames() { + files[n] = []byte("dawg:" + n) + } + return tarGz(t, files, nil) +} + +// tarGz builds a gzip+tar archive. regular maps a member name to its bytes; extra +// lets a test inject crafted headers (symlinks, oversized members, junk entries). +func tarGz(t *testing.T, regular map[string][]byte, extra []*tar.Header) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, data := range regular { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("write header %s: %v", name, err) + } + if _, err := tw.Write(data); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + for _, hdr := range extra { + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write extra header %s: %v", hdr.Name, err) + } + if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 { + if _, err := tw.Write(bytes.Repeat([]byte("x"), int(hdr.Size))); err != nil { + t.Fatalf("write extra %s: %v", hdr.Name, err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +} + +func TestParseVersionFromName(t *testing.T) { + cases := []struct { + name string + want string + wantErr bool + }{ + {"scrabble-dawg-v1.2.3.tar.gz", "v1.2.3", false}, + {"scrabble-dawg-v10.0.1.tar.gz", "v10.0.1", false}, + {"scrabble-dawg-v1.2.tar.gz", "", true}, + {"scrabble-dawg-1.2.3.tar.gz", "", true}, + {"scrabble-dawg-v1.2.3.zip", "", true}, + {"dawg-v1.2.3.tar.gz", "", true}, + {"scrabble-dawg-v1.2.3.tar.gz.exe", "", true}, + {"", "", true}, + } + for _, c := range cases { + got, err := ParseVersionFromName(c.name) + if c.wantErr { + if err == nil { + t.Errorf("ParseVersionFromName(%q) = %q, want error", c.name, got) + } + continue + } + if err != nil || got != c.want { + t.Errorf("ParseVersionFromName(%q) = %q, %v; want %q", c.name, got, err, c.want) + } + } +} + +func TestValidVersion(t *testing.T) { + valid := []string{"v1.0.0", "v10.20.30", "v0.0.1"} + invalid := []string{"v1.0", "1.0.0", "v1.0.0.0", "v1.0.0/..", "v1.0.0 ", "", "vx.y.z", "../v1.0.0"} + for _, v := range valid { + if !ValidVersion(v) { + t.Errorf("ValidVersion(%q) = false, want true", v) + } + } + for _, v := range invalid { + if ValidVersion(v) { + t.Errorf("ValidVersion(%q) = true, want false", v) + } + } +} + +func TestExtractAcceptsFullArchive(t *testing.T) { + dir := t.TempDir() + variants, err := Extract(bytes.NewReader(fullArchive(t)), dir) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if len(variants) != len(engine.Variants()) { + t.Errorf("variants = %v, want all %d", variants, len(engine.Variants())) + } + for _, n := range dawgNames() { + if _, err := os.Stat(filepath.Join(dir, n)); err != nil { + t.Errorf("missing extracted %s: %v", n, err) + } + } +} + +func TestExtractIgnoresUnexpectedAndTraversal(t *testing.T) { + dir := t.TempDir() + files := map[string][]byte{ + "pkg/en_sowpods.dawg": []byte("ok"), // a subdir path is stripped to its base + "README.txt": []byte("junk"), // an unexpected name is ignored + } + // A traversal name with a recognised base must still land inside destDir. + traversal := &tar.Header{Name: "../ru_scrabble.dawg", Mode: 0o644, Size: 2, Typeflag: tar.TypeReg} + // A symlink, even named like a DAWG, is ignored. + symlink := &tar.Header{Name: "erudit_ru.dawg", Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink} + + if _, err := Extract(bytes.NewReader(tarGz(t, files, []*tar.Header{traversal, symlink})), dir); err != nil { + t.Fatalf("Extract: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "README.txt")); err == nil { + t.Error("README.txt was extracted; unexpected files must be ignored") + } + if _, err := os.Stat(filepath.Join(dir, "en_sowpods.dawg")); err != nil { + t.Errorf("en_sowpods.dawg not extracted from a subdir entry: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "ru_scrabble.dawg")); err != nil { + t.Errorf("ru_scrabble.dawg not extracted from a traversal entry: %v", err) + } + // Nothing escaped the destination directory. + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "ru_scrabble.dawg")); err == nil { + t.Error("path traversal wrote outside the destination directory") + } + if _, err := os.Stat(filepath.Join(dir, "erudit_ru.dawg")); err == nil { + t.Error("a symlink member was materialised") + } +} + +func TestExtractRejectsOversizeMember(t *testing.T) { + old := maxMemberBytes + maxMemberBytes = 8 + defer func() { maxMemberBytes = old }() + + files := map[string][]byte{"en_sowpods.dawg": bytes.Repeat([]byte("x"), 64)} + if _, err := Extract(bytes.NewReader(tarGz(t, files, nil)), t.TempDir()); err == nil { + t.Error("Extract accepted an oversize member, want error") + } +} + +func TestExtractRejectsTooManyEntries(t *testing.T) { + var extra []*tar.Header + for i := 0; i < maxEntries+1; i++ { + extra = append(extra, &tar.Header{Name: fmt.Sprintf("junk-%d.bin", i), Mode: 0o644, Size: 1, Typeflag: tar.TypeReg}) + } + if _, err := Extract(bytes.NewReader(tarGz(t, nil, extra)), t.TempDir()); err == nil { + t.Error("Extract accepted an archive with too many entries, want error") + } +} + +func TestManagerStageInstall(t *testing.T) { + dir := t.TempDir() + m := New(dir) + + token, variants, err := m.Stage(bytes.NewReader(fullArchive(t))) + if err != nil { + t.Fatalf("Stage: %v", err) + } + if len(variants) != len(engine.Variants()) { + t.Fatalf("staged variants = %v, want all", variants) + } + staged, err := m.StagedDir(token) + if err != nil { + t.Fatalf("StagedDir: %v", err) + } + if !strings.Contains(staged, filepath.Join(".staging", token)) { + t.Errorf("staged dir %q not under .staging/%s", staged, token) + } + + if m.VersionExists("v1.0.0") { + t.Fatal("v1.0.0 should not exist before install") + } + dest, err := m.Install(token, "v1.0.0") + if err != nil { + t.Fatalf("Install: %v", err) + } + if dest != filepath.Join(dir, "v1.0.0") { + t.Errorf("install dest = %q, want %q", dest, filepath.Join(dir, "v1.0.0")) + } + if !m.VersionExists("v1.0.0") { + t.Error("VersionExists(v1.0.0) = false after install") + } + for _, n := range dawgNames() { + if _, err := os.Stat(filepath.Join(dest, n)); err != nil { + t.Errorf("installed %s missing: %v", n, err) + } + } +} + +func TestManagerInstallRejectsExistingVersion(t *testing.T) { + dir := t.TempDir() + m := New(dir) + token, _, err := m.Stage(bytes.NewReader(fullArchive(t))) + if err != nil { + t.Fatalf("Stage: %v", err) + } + if _, err := m.Install(token, "v2.0.0"); err != nil { + t.Fatalf("first Install: %v", err) + } + token2, _, err := m.Stage(bytes.NewReader(fullArchive(t))) + if err != nil { + t.Fatalf("second Stage: %v", err) + } + if _, err := m.Install(token2, "v2.0.0"); err == nil { + t.Error("Install overwrote an existing version, want error (versions are immutable)") + } +} + +func TestStagedDirRejectsBadToken(t *testing.T) { + m := New(t.TempDir()) + for _, bad := range []string{"", "../etc", "ABC", "zz", strings.Repeat("g", 32)} { + if _, err := m.StagedDir(bad); err == nil { + t.Errorf("StagedDir(%q) = nil error, want rejection", bad) + } + } +} diff --git a/backend/internal/engine/diff.go b/backend/internal/engine/diff.go new file mode 100644 index 0000000..cdbc660 --- /dev/null +++ b/backend/internal/engine/diff.go @@ -0,0 +1,134 @@ +package engine + +import ( + "bytes" + "fmt" + "maps" + "path/filepath" + "sort" + + dawg "github.com/iliadenisov/dafsa" +) + +// WordDiff is the set difference between two dictionaries of one variant: the +// words present only in the new dictionary (Added) and only in the old one +// (Removed), decoded to characters and each sorted by the variant's alphabet +// order. It drives the admin dictionary-update preview. +type WordDiff struct { + Added []string + Removed []string +} + +// DictFiles returns a copy of the variant-to-committed-DAWG-filename map. It lets +// callers outside the package (the dictionary-admin upload validation) check an +// archive against the expected file set without sharing the engine's own map. +func DictFiles() map[Variant]string { + out := make(map[Variant]string, len(dictFiles)) + maps.Copy(out, dictFiles) + return out +} + +// OpenFinder loads the committed DAWG of variant v from dir and returns its +// finder. The caller owns the finder and must Close it. It backs enumeration of +// a staged, not-yet-registered dictionary version. +func OpenFinder(dir string, v Variant) (dawg.Finder, error) { + name, ok := dictFiles[v] + if !ok { + return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v) + } + path := filepath.Join(dir, name) + finder, err := dawg.Load(path) + if err != nil { + return nil, fmt.Errorf("engine: load %s dictionary from %s: %w", v, path, err) + } + return finder, nil +} + +// Finder returns the loaded finder for the (variant, version) pair so its words +// can be enumerated, or ErrUnknownVariant / ErrUnknownVersion when that +// dictionary is not resident. The finder remains owned by the registry; callers +// must not Close it. +func (r *Registry) Finder(v Variant, version string) (dawg.Finder, error) { + r.mu.RLock() + defer r.mu.RUnlock() + versions, ok := r.entries[v] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v) + } + e, ok := versions[version] + if !ok { + return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version) + } + return e.finder, nil +} + +// DiffWords compares the old and new finders of variant v and returns the words +// added and removed, decoded through v's alphabet. Both dictionaries are +// enumerated as alphabet-index bytes and merged; only the differing words are +// decoded, so a full-dictionary comparison does not materialise two large string +// sets. +func DiffWords(v Variant, old, updated dawg.Finder) (WordDiff, error) { + rs, ok := v.ruleset() + if !ok { + return WordDiff{}, fmt.Errorf("%w: %d", ErrUnknownVariant, v) + } + oldWords := collectWords(old) + newWords := collectWords(updated) + + var diff WordDiff + i, j := 0, 0 + for i < len(oldWords) && j < len(newWords) { + switch cmp := bytes.Compare(oldWords[i], newWords[j]); { + case cmp == 0: + i++ + j++ + case cmp < 0: + s, err := rs.Alphabet.Decode(oldWords[i]) + if err != nil { + return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err) + } + diff.Removed = append(diff.Removed, s) + i++ + default: + s, err := rs.Alphabet.Decode(newWords[j]) + if err != nil { + return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err) + } + diff.Added = append(diff.Added, s) + j++ + } + } + for ; i < len(oldWords); i++ { + s, err := rs.Alphabet.Decode(oldWords[i]) + if err != nil { + return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err) + } + diff.Removed = append(diff.Removed, s) + } + for ; j < len(newWords); j++ { + s, err := rs.Alphabet.Decode(newWords[j]) + if err != nil { + return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err) + } + diff.Added = append(diff.Added, s) + } + return diff, nil +} + +// collectWords enumerates every complete word of f as a copy of its +// alphabet-index bytes, sorted ascending. The dawg already enumerates in index +// order; the explicit sort makes the merge in DiffWords robust to that being an +// implementation detail. +func collectWords(f dawg.Finder) [][]byte { + out := make([][]byte, 0, f.NumAdded()) + f.EnumerateB(func(_ int, word []byte, final bool) dawg.EnumerationResult { + if final && len(word) > 0 { + cp := make([]byte, len(word)) + copy(cp, word) + out = append(out, cp) + } + return dawg.Continue + }) + sort.Slice(out, func(a, b int) bool { return bytes.Compare(out[a], out[b]) < 0 }) + return out +} diff --git a/backend/internal/engine/diff_test.go b/backend/internal/engine/diff_test.go new file mode 100644 index 0000000..79fba25 --- /dev/null +++ b/backend/internal/engine/diff_test.go @@ -0,0 +1,134 @@ +package engine + +import ( + "errors" + "testing" + + "github.com/iliadenisov/alphabet" + dawg "github.com/iliadenisov/dafsa" +) + +// buildFinderB builds an in-memory finder over idx from the given words, each +// expressed as alphabet-index bytes. The words must be supplied in strictly +// increasing alphabet-index order, as the builder requires. +func buildFinderB(t *testing.T, idx alphabet.Indexer, words ...[]byte) dawg.Finder { + t.Helper() + b := dawg.New(idx) + for _, w := range words { + if err := b.AddB(w); err != nil { + t.Fatalf("addB %v: %v", w, err) + } + } + return b.Finish() +} + +// mustDecode decodes index bytes through idx or fails the test. +func mustDecode(t *testing.T, idx alphabet.Indexer, word []byte) string { + t.Helper() + s, err := idx.Decode(word) + if err != nil { + t.Fatalf("decode %v: %v", word, err) + } + return s +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestDiffWords checks that DiffWords reports the words present only in the new +// dictionary as added and those present only in the old one as removed, decoded +// to characters through the variant's alphabet. +func TestDiffWords(t *testing.T) { + rs, _ := VariantEnglish.ruleset() + idx := rs.Alphabet + + old := buildFinderB(t, idx, []byte{0}, []byte{0, 1}, []byte{2}) + defer func() { _ = old.Close() }() + updated := buildFinderB(t, idx, []byte{0}, []byte{2}, []byte{2, 3}) + defer func() { _ = updated.Close() }() + + diff, err := DiffWords(VariantEnglish, old, updated) + if err != nil { + t.Fatalf("DiffWords: %v", err) + } + + wantAdded := []string{mustDecode(t, idx, []byte{2, 3})} + wantRemoved := []string{mustDecode(t, idx, []byte{0, 1})} + if !equalStrings(diff.Added, wantAdded) { + t.Errorf("added = %v, want %v", diff.Added, wantAdded) + } + if !equalStrings(diff.Removed, wantRemoved) { + t.Errorf("removed = %v, want %v", diff.Removed, wantRemoved) + } +} + +// TestDiffWordsIdentical checks that comparing a dictionary with itself yields an +// empty diff. +func TestDiffWordsIdentical(t *testing.T) { + rs, _ := VariantEnglish.ruleset() + f := buildFinderB(t, rs.Alphabet, []byte{0}, []byte{1}, []byte{1, 2}) + defer func() { _ = f.Close() }() + + diff, err := DiffWords(VariantEnglish, f, f) + if err != nil { + t.Fatalf("DiffWords: %v", err) + } + if len(diff.Added) != 0 || len(diff.Removed) != 0 { + t.Errorf("diff = %+v, want empty", diff) + } +} + +// TestDiffWordsUnknownVariant checks that an unrecognised variant is rejected. +func TestDiffWordsUnknownVariant(t *testing.T) { + rs, _ := VariantEnglish.ruleset() + f := buildFinderB(t, rs.Alphabet, []byte{0}) + defer func() { _ = f.Close() }() + if _, err := DiffWords(Variant(250), f, f); !errors.Is(err, ErrUnknownVariant) { + t.Errorf("err = %v, want ErrUnknownVariant", err) + } +} + +// TestOpenFinder checks that OpenFinder loads a committed DAWG from a directory. +func TestOpenFinder(t *testing.T) { + f, err := OpenFinder(testDictDir(), VariantEnglish) + if err != nil { + t.Fatalf("OpenFinder: %v", err) + } + defer func() { _ = f.Close() }() + if f.NumAdded() == 0 { + t.Error("english dictionary is empty") + } +} + +// TestRegistryFinder checks that Finder returns the resident finder for a pair +// and the right sentinel when the version is absent. +func TestRegistryFinder(t *testing.T) { + if _, err := testReg.Finder(VariantEnglish, testVersion); err != nil { + t.Errorf("Finder(scrabble_en, %q): %v", testVersion, err) + } + if _, err := testReg.Finder(VariantEnglish, "absent"); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("Finder absent version err = %v, want ErrUnknownVersion", err) + } +} + +// TestDictFiles checks that DictFiles exposes the variant filename map as a copy +// the caller cannot use to mutate the engine's own map. +func TestDictFiles(t *testing.T) { + files := DictFiles() + if len(files) != len(Variants()) { + t.Fatalf("DictFiles has %d entries, want %d", len(files), len(Variants())) + } + files[VariantEnglish] = "tampered" + if again := DictFiles(); again[VariantEnglish] == "tampered" { + t.Error("DictFiles returned a shared map; mutating it changed the engine's map") + } +} diff --git a/backend/internal/engine/registry.go b/backend/internal/engine/registry.go index be592b5..5cf9c11 100644 --- a/backend/internal/engine/registry.go +++ b/backend/internal/engine/registry.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" dawg "github.com/iliadenisov/dafsa" @@ -83,7 +84,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) { return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err) } for _, e := range entries { - if !e.IsDir() || e.Name() == bootVersion { + // Skip non-directories, the boot version (already loaded as the flat dir) + // and dot-prefixed directories (the upload staging area, dir/.staging/). + if !e.IsDir() || e.Name() == bootVersion || strings.HasPrefix(e.Name(), ".") { continue } if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil { diff --git a/backend/internal/engine/reload_test.go b/backend/internal/engine/reload_test.go index 50a1c7c..75c6122 100644 --- a/backend/internal/engine/reload_test.go +++ b/backend/internal/engine/reload_test.go @@ -91,6 +91,27 @@ func TestOpenWithVersionsScansSubdirs(t *testing.T) { } } +// TestOpenWithVersionsSkipsDotDirs verifies the boot scan ignores dot-prefixed +// subdirectories (the upload staging area), so a half-extracted archive there +// never becomes a resident version. +func TestOpenWithVersionsSkipsDotDirs(t *testing.T) { + dir := t.TempDir() + for _, v := range Variants() { + copyDawg(t, testDictDir(), dir, v) + } + copyDawg(t, testDictDir(), filepath.Join(dir, ".staging"), VariantEnglish) + + reg, err := OpenWithVersions(dir, "v1") + if err != nil { + t.Fatalf("open with versions: %v", err) + } + defer func() { _ = reg.Close() }() + + if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" { + t.Errorf("scrabble_en versions = %v, want only [v1] (.staging skipped)", 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) { diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 971822f..1817b9a 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -9,6 +9,7 @@ import ( "slices" "strconv" "strings" + "sync" "time" "github.com/google/uuid" @@ -30,12 +31,16 @@ type Service struct { registry *engine.Registry cache *gameCache locks *keyedMutex - version string - clock func() time.Time - rng func() int64 - pub notify.Publisher - metrics *gameMetrics - log *zap.Logger + // verMu guards version, the dictionary version new games pin. It is read on + // the game-create path and rewritten when an operator activates a new version + // through the admin console, so access is serialised. + verMu sync.RWMutex + version string + clock func() time.Time + rng func() int64 + pub notify.Publisher + metrics *gameMetrics + log *zap.Logger } // NewService constructs a Service. store and accounts wrap the same pool; @@ -70,6 +75,62 @@ func (svc *Service) SetNotifier(p notify.Publisher) { } } +// activeVersion returns the dictionary version new games currently pin. +func (svc *Service) activeVersion() string { + svc.verMu.RLock() + defer svc.verMu.RUnlock() + return svc.version +} + +// ActiveVersion returns the dictionary version new games currently pin. It backs +// the admin console's "active version" display. +func (svc *Service) ActiveVersion() string { + return svc.activeVersion() +} + +// InitActiveVersion reconciles the persisted active dictionary version with the +// registry at startup. If a version was persisted and is resident, it becomes the +// active version; otherwise the configured seed version is kept and persisted as +// the initial active version. Call once during wiring, before serving traffic. +func (svc *Service) InitActiveVersion(ctx context.Context) error { + persisted, ok, err := svc.store.GetActiveDictVersion(ctx) + if err != nil { + return err + } + if ok && svc.versionResident(persisted) { + svc.verMu.Lock() + svc.version = persisted + svc.verMu.Unlock() + return nil + } + return svc.store.SetActiveDictVersion(ctx, svc.activeVersion()) +} + +// SetActiveVersion records version as the active dictionary version, persisting it +// and updating the in-memory pointer new games read. The caller must have made the +// version resident in the registry first. +func (svc *Service) SetActiveVersion(ctx context.Context, version string) error { + if err := svc.store.SetActiveDictVersion(ctx, version); err != nil { + return err + } + svc.verMu.Lock() + svc.version = version + svc.verMu.Unlock() + return nil +} + +// versionResident reports whether any variant of version is loaded in the +// registry, so a persisted active version pointing at a no-longer-present version +// (e.g. after a volume loss) falls back to the seed. +func (svc *Service) versionResident(version string) bool { + for _, v := range engine.Variants() { + if _, err := svc.registry.Solver(v, version); err == nil { + return true + } + } + return false +} + // Create starts and persists a new game seating the given accounts in turn order // (seat 0 first), deals the racks, and warms the live-game cache. It validates // the player count (2–4), the move clock, the hint allowance and that every seat @@ -106,9 +167,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro if seed == 0 { seed = svc.rng() } + // Capture the active version once so the engine game and the persisted + // dict_version agree even if an operator activates a new version concurrently. + version := svc.activeVersion() g, err := engine.New(svc.registry, engine.Options{ Variant: params.Variant, - Version: svc.version, + Version: version, Players: len(params.Seats), Seed: seed, DropoutTiles: params.DropoutTiles, @@ -128,7 +192,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro ins := gameInsert{ id: id, variant: params.Variant.String(), - dictVersion: svc.version, + dictVersion: version, seed: seed, players: len(params.Seats), turnTimeoutSecs: int(timeout / time.Second), @@ -180,7 +244,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params ins := gameInsert{ id: id, variant: params.Variant.String(), - dictVersion: svc.version, + dictVersion: svc.activeVersion(), seed: seed, players: 2, turnTimeoutSecs: int(timeout / time.Second), @@ -759,7 +823,7 @@ func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, } // MarkChangesApplied records that every pending accepted change for variant has -// been folded into the dictionary version that was just hot-reloaded, removing +// been folded into the dictionary version that was just installed, 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) diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 1ed55ef..3bd9946 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -767,6 +767,41 @@ func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string) return n, nil } +// GetActiveDictVersion returns the persisted active dictionary version and true, +// or ("", false, nil) when none has been recorded yet (a fresh database). It reads +// the dictionary_state singleton (docs/ARCHITECTURE.md §5). +func (s *Store) GetActiveDictVersion(ctx context.Context) (string, bool, error) { + stmt := postgres.SELECT(table.DictionaryState.ActiveVersion). + FROM(table.DictionaryState). + WHERE(table.DictionaryState.ID.EQ(postgres.Bool(true))) + var row model.DictionaryState + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return "", false, nil + } + return "", false, fmt.Errorf("game: get active dict version: %w", err) + } + return row.ActiveVersion, true, nil +} + +// SetActiveDictVersion records version as the active dictionary version, upserting +// the dictionary_state singleton so the choice survives a restart. +func (s *Store) SetActiveDictVersion(ctx context.Context, version string) error { + now := time.Now().UTC() + stmt := table.DictionaryState. + INSERT(table.DictionaryState.ID, table.DictionaryState.ActiveVersion, table.DictionaryState.UpdatedAt). + VALUES(true, version, postgres.TimestampzT(now)). + ON_CONFLICT(table.DictionaryState.ID). + DO_UPDATE(postgres.SET( + table.DictionaryState.ActiveVersion.SET(postgres.String(version)), + table.DictionaryState.UpdatedAt.SET(postgres.TimestampzT(now)), + )) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("game: set active dict version: %w", err) + } + return 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) { diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index d5fe2f5..dccd8b7 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -248,7 +248,7 @@ type Complaint struct { // 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. +// dictionary version is installed they are marked applied. type DictionaryChange struct { ComplaintID uuid.UUID Variant engine.Variant diff --git a/backend/internal/inttest/dictionary_update_test.go b/backend/internal/inttest/dictionary_update_test.go new file mode 100644 index 0000000..a829514 --- /dev/null +++ b/backend/internal/inttest/dictionary_update_test.go @@ -0,0 +1,220 @@ +//go:build integration + +package inttest + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "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" +) + +// TestDictionaryUpdateFlow drives the admin dictionary update end to end over HTTP +// against real stores and an isolated dictionary directory: upload a release +// archive, preview the diff, install it, and confirm it becomes the active version, +// is written to disk, survives a restart (a fresh service re-adopts it), pins new +// games while leaving an in-progress game on its old version, and is immutable. +func TestDictionaryUpdateFlow(t *testing.T) { + ctx := context.Background() + + // Isolated dictionary directory seeded with the real DAWGs as the flat boot + // version v1.0.0, with its own registry and service so the shared fixtures are + // untouched. + dir := t.TempDir() + seedDawgs(t, dictDir(), dir) + reg, err := engine.OpenWithVersions(dir, "v1.0.0") + if err != nil { + t.Fatalf("open registry: %v", err) + } + defer func() { _ = reg.Close() }() + + svc := newGameServiceOn(dir, "v1.0.0", reg) + if err := svc.InitActiveVersion(ctx); err != nil { + t.Fatalf("init active version: %v", err) + } + if got := svc.ActiveVersion(); got != "v1.0.0" { + t.Fatalf("active version = %q, want v1.0.0", got) + } + + // An in-progress game pinned to the original version. + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + oldGame, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1}) + if err != nil { + t.Fatalf("create old game: %v", err) + } + if oldGame.DictVersion != "v1.0.0" { + t.Fatalf("old game dict_version = %q, want v1.0.0", oldGame.DictVersion) + } + + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: reg, DictDir: dir, + }) + h := srv.Handler() + base := "http://admin.test/_gm" + archive := releaseArchive(t, dir) + const fileName = "scrabble-dawg-v9.9.9.tar.gz" + + // Upload without a same-origin header is rejected by the CSRF guard. + if code, _ := consoleUpload(h, base+"/dictionary/upload", fileName, archive, ""); code != http.StatusForbidden { + t.Fatalf("upload without origin = %d, want 403", code) + } + + // Upload with origin renders the preview carrying the version and a token. + code, body := consoleUpload(h, base+"/dictionary/upload", fileName, archive, "http://admin.test") + if code != http.StatusOK || !strings.Contains(body, "v9.9.9") { + t.Fatalf("upload = %d, has version=%v", code, strings.Contains(body, "v9.9.9")) + } + token := tokenFrom(t, body) + + // Confirm installs, loads and activates the new version. + code, body = consoleDo(h, http.MethodPost, base+"/dictionary/install", "token="+token+"&version=v9.9.9", "http://admin.test") + if code != http.StatusOK || !strings.Contains(body, "Updated") { + t.Fatalf("install = %d, has Updated=%v", code, strings.Contains(body, "Updated")) + } + + // The new version is active, resident for every variant, and written to disk. + if got := svc.ActiveVersion(); got != "v9.9.9" { + t.Fatalf("active version after install = %q, want v9.9.9", got) + } + for _, v := range engine.Variants() { + if _, err := reg.Solver(v, "v9.9.9"); err != nil { + t.Errorf("variant %s v9.9.9 not resident: %v", v, err) + } + } + if _, err := os.Stat(filepath.Join(dir, "v9.9.9", "en_sowpods.dawg")); err != nil { + t.Errorf("installed dawg missing on disk: %v", err) + } + + // The choice survives a restart: a fresh service over the same DB and registry + // re-adopts the persisted active version. + restarted := newGameServiceOn(dir, "v1.0.0", reg) + if err := restarted.InitActiveVersion(ctx); err != nil { + t.Fatalf("restart init: %v", err) + } + if got := restarted.ActiveVersion(); got != "v9.9.9" { + t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got) + } + + // New games pin the new version; the in-progress game keeps its own, still resident. + newGame, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{provisionAccount(t), provisionAccount(t)}, TurnTimeout: 24 * time.Hour, Seed: 2}) + if err != nil { + t.Fatalf("create new game: %v", err) + } + if newGame.DictVersion != "v9.9.9" { + t.Errorf("new game dict_version = %q, want v9.9.9", newGame.DictVersion) + } + if _, err := reg.Solver(engine.VariantEnglish, oldGame.DictVersion); err != nil { + t.Errorf("old game's version %s no longer resident: %v", oldGame.DictVersion, err) + } + + // Re-uploading the now-resident version is rejected (versions are immutable). + if _, body := consoleUpload(h, base+"/dictionary/upload", fileName, archive, "http://admin.test"); !strings.Contains(body, "Already resident") { + t.Errorf("re-upload of resident version not rejected: %q", body) + } +} + +// newGameServiceOn builds a game service over the shared pool but a caller-supplied +// dictionary directory, version and registry, for the isolated dictionary tests. +func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service { + return game.NewService( + game.NewStore(testDB), account.NewStore(testDB), reg, + game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour}, + zap.NewNop(), + ) +} + +// seedDawgs copies the committed DAWG of every variant from src into dst (flat). +func seedDawgs(t *testing.T, src, dst string) { + t.Helper() + for _, name := range engine.DictFiles() { + copyFile(t, filepath.Join(src, name), filepath.Join(dst, name)) + } +} + +// releaseArchive packs the three DAWGs from dir into an in-memory gzip+tar release +// archive, the shape consoleUploadDictionary expects. +func releaseArchive(t *testing.T, dir string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, name := range engine.DictFiles() { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("tar header %s: %v", name, err) + } + if _, err := tw.Write(data); err != nil { + t.Fatalf("tar write %s: %v", name, err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +} + +func copyFile(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("read %s: %v", src, err) + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + t.Fatalf("write %s: %v", dst, err) + } +} + +var tokenRE = regexp.MustCompile(`name="token" value="([0-9a-f]{32})"`) + +// tokenFrom extracts the staging token from a rendered preview page. +func tokenFrom(t *testing.T, body string) string { + t.Helper() + m := tokenRE.FindStringSubmatch(body) + if m == nil { + t.Fatalf("no staging token in preview body:\n%s", body) + } + return m[1] +} + +// consoleUpload posts a multipart archive to target as the "archive" file field, +// optionally with an Origin header, returning the status and body. +func consoleUpload(h http.Handler, target, filename string, data []byte, origin string) (int, string) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("archive", filename) + _, _ = io.Copy(fw, bytes.NewReader(data)) + _ = mw.Close() + + req := httptest.NewRequest(http.MethodPost, target, &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + if origin != "" { + req.Header.Set("Origin", origin) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code, rec.Body.String() +} diff --git a/backend/internal/postgres/jet/backend/model/dictionary_state.go b/backend/internal/postgres/jet/backend/model/dictionary_state.go new file mode 100644 index 0000000..afbebc8 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/dictionary_state.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "time" +) + +type DictionaryState struct { + ID bool `sql:"primary_key"` + ActiveVersion string + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/dictionary_state.go b/backend/internal/postgres/jet/backend/table/dictionary_state.go new file mode 100644 index 0000000..680e6d9 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/dictionary_state.go @@ -0,0 +1,84 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var DictionaryState = newDictionaryStateTable("backend", "dictionary_state", "") + +type dictionaryStateTable struct { + postgres.Table + + // Columns + ID postgres.ColumnBool + ActiveVersion postgres.ColumnString + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type DictionaryStateTable struct { + dictionaryStateTable + + EXCLUDED dictionaryStateTable +} + +// AS creates new DictionaryStateTable with assigned alias +func (a DictionaryStateTable) AS(alias string) *DictionaryStateTable { + return newDictionaryStateTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new DictionaryStateTable with assigned schema name +func (a DictionaryStateTable) FromSchema(schemaName string) *DictionaryStateTable { + return newDictionaryStateTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new DictionaryStateTable with assigned table prefix +func (a DictionaryStateTable) WithPrefix(prefix string) *DictionaryStateTable { + return newDictionaryStateTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new DictionaryStateTable with assigned table suffix +func (a DictionaryStateTable) WithSuffix(suffix string) *DictionaryStateTable { + return newDictionaryStateTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newDictionaryStateTable(schemaName, tableName, alias string) *DictionaryStateTable { + return &DictionaryStateTable{ + dictionaryStateTable: newDictionaryStateTableImpl(schemaName, tableName, alias), + EXCLUDED: newDictionaryStateTableImpl("", "excluded", ""), + } +} + +func newDictionaryStateTableImpl(schemaName, tableName, alias string) dictionaryStateTable { + var ( + IDColumn = postgres.BoolColumn("id") + ActiveVersionColumn = postgres.StringColumn("active_version") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{IDColumn, ActiveVersionColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{ActiveVersionColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{IDColumn, UpdatedAtColumn} + ) + + return dictionaryStateTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + ActiveVersion: ActiveVersionColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 70d757d..8f79b5d 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -15,6 +15,7 @@ func UseSchema(schema string) { Blocks = Blocks.FromSchema(schema) ChatMessages = ChatMessages.FromSchema(schema) Complaints = Complaints.FromSchema(schema) + DictionaryState = DictionaryState.FromSchema(schema) EmailConfirmations = EmailConfirmations.FromSchema(schema) FriendCodes = FriendCodes.FromSchema(schema) Friendships = Friendships.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index 2a0edd1..eaadf3d 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -158,7 +158,7 @@ CREATE TABLE game_moves ( -- Word-check complaints captured in the context of a game's pinned dictionary. The -- admin review queue resolves them with a disposition that also feeds the offline -- dictionary-rebuild pipeline: an accepted complaint records whether the word is to be --- added or removed, and is marked applied once a rebuilt version is hot-reloaded. +-- added or removed, and is marked applied once a rebuilt version is installed. CREATE TABLE complaints ( complaint_id uuid PRIMARY KEY, complainant_id uuid NOT NULL REFERENCES accounts (account_id), @@ -180,6 +180,19 @@ CREATE TABLE complaints ( ); CREATE INDEX complaints_status_idx ON complaints (status); +-- The active dictionary version new games pin, kept as the single source of truth +-- so it survives a restart (the in-memory engine.Registry is rebuilt from the +-- dictionary directory on every boot). A singleton row: id is always true. The +-- admin dictionary-update flow sets active_version after it writes and loads a new +-- version; new games read it, while in-flight games keep the version stamped on +-- their games.dict_version (docs/ARCHITECTURE.md §5). +CREATE TABLE dictionary_state ( + id boolean PRIMARY KEY DEFAULT true, + active_version text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT dictionary_state_singleton_chk CHECK (id) +); + -- Per-account lifetime statistics, recomputed incrementally on each game finish. -- Guests have no durable stats. A draw increments draws only. max_word_points is the -- best single move score (folding in every word the move formed and the all-tiles bonus). diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index fe63d53..6a765ea 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -7,7 +7,6 @@ import ( "html/template" "net/http" "net/url" - "path/filepath" "strconv" "strings" "time" @@ -18,6 +17,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/dictadmin" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/ratewatch" @@ -60,7 +60,8 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/messages", s.consoleMessages) gm.GET("/messages.csv", s.consoleMessagesCSV) gm.GET("/dictionary", s.consoleDictionary) - gm.POST("/dictionary/reload", s.consoleReloadDictionary) + gm.POST("/dictionary/upload", s.consoleUploadDictionary) + gm.POST("/dictionary/install", s.consoleInstallDictionary) gm.POST("/dictionary/changes/apply", s.consoleApplyChanges) gm.GET("/broadcast", s.consoleBroadcast) gm.POST("/broadcast", s.consolePostBroadcast) @@ -70,7 +71,7 @@ func (s *Server) registerConsole(router *gin.Engine) { // dictionary versions. func (s *Server) consoleDashboard(c *gin.Context) { ctx := c.Request.Context() - view := adminconsole.DashboardView{Variants: s.variantVersions()} + view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()} view.Accounts, _ = s.accounts.CountAccounts(ctx) view.Games, _ = s.games.CountGames(ctx, "") view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive) @@ -473,7 +474,7 @@ func (s *Server) consoleResolveComplaint(c *gin.Context) { // consoleDictionary renders the resident versions and the pending wordlist changes. func (s *Server) consoleDictionary(c *gin.Context) { - view := adminconsole.DictionaryView{Variants: s.variantVersions()} + view := adminconsole.DictionaryView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()} if changes, err := s.games.DictionaryChanges(c.Request.Context()); err == nil { for _, ch := range changes { action := "remove" @@ -486,28 +487,172 @@ func (s *Server) consoleDictionary(c *gin.Context) { 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") +// previewSampleCap bounds how many added/removed words the update preview lists per +// variant; the full totals are always shown. +const previewSampleCap = 200 + +// largeRemovalThreshold flags a removal large enough that the operator should pause +// before confirming: a rebuilt dictionary normally drops a handful of words, not +// thousands. +const largeRemovalThreshold = 1000 + +// consoleUploadDictionary accepts a scrabble-dawg release archive, stages it under +// the dictionary directory and renders the per-variant word diff against the active +// dictionary. The operator confirms it with consoleInstallDictionary. The archive +// is bounded, its file name fixes the version, and a version already resident is +// rejected (versions are immutable). +func (s *Server) consoleUploadDictionary(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, dictadmin.MaxArchiveBytes) + file, header, err := c.Request.FormFile("archive") + if err != nil { + s.renderConsoleMessage(c, "Upload failed", "choose a scrabble-dawg-vX.Y.Z.tar.gz archive (max 64 MiB)", "/_gm/dictionary") return } - dir := filepath.Join(s.dictDir, version) - loaded, err := s.registry.LoadAvailable(dir, version) + defer func() { _ = file.Close() }() + + version, err := dictadmin.ParseVersionFromName(header.Filename) + if err != nil { + s.renderConsoleMessage(c, "Upload failed", "the file name must be scrabble-dawg-vX.Y.Z.tar.gz", "/_gm/dictionary") + return + } + if s.versionResident(version) { + s.renderConsoleMessage(c, "Already resident", fmt.Sprintf("version %s is already loaded; versions are immutable", version), "/_gm/dictionary") + return + } + + mgr := dictadmin.New(s.dictDir) + token, variants, err := mgr.Stage(file) 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") + if len(variants) != len(engine.Variants()) { + mgr.Discard(token) + s.renderConsoleMessage(c, "Incomplete archive", + fmt.Sprintf("the archive carries %d of %d variants; a release must contain all of them", len(variants), len(engine.Variants())), + "/_gm/dictionary") return } - names := make([]string, len(loaded)) - for i, v := range loaded { - names[i] = v.String() + + staged, err := mgr.StagedDir(token) + if err != nil { + s.consoleError(c, err) + return } - s.renderConsoleMessage(c, "Reloaded", fmt.Sprintf("loaded %v as version %q", names, version), "/_gm/dictionary") + rows, err := s.diffStaged(staged, variants) + if err != nil { + mgr.Discard(token) + s.consoleError(c, err) + return + } + s.renderConsole(c, "dictionary_preview", "dictionary", "Dictionary update", adminconsole.DictionaryPreviewView{ + Version: version, + Token: token, + ActiveVersion: s.games.ActiveVersion(), + Variants: rows, + }) +} + +// consoleInstallDictionary promotes a staged upload into its version directory, +// loads it into the registry and makes it the active version new games pin. The +// version is taken from the (editable) form field, re-validated and checked for +// immutability. +func (s *Server) consoleInstallDictionary(c *gin.Context) { + token := trimForm(c, "token") + version := trimForm(c, "version") + if !dictadmin.ValidVersion(version) { + s.renderConsoleMessage(c, "Install failed", "the version must look like vX.Y.Z", "/_gm/dictionary") + return + } + if s.versionResident(version) { + s.renderConsoleMessage(c, "Already resident", fmt.Sprintf("version %s is already loaded; versions are immutable", version), "/_gm/dictionary") + return + } + + mgr := dictadmin.New(s.dictDir) + dest, err := mgr.Install(token, version) + if err != nil { + s.consoleError(c, err) + return + } + loaded, err := s.registry.LoadAvailable(dest, version) + if err != nil { + s.consoleError(c, err) + return + } + if len(loaded) != len(engine.Variants()) { + s.renderConsoleMessage(c, "Install incomplete", + fmt.Sprintf("loaded %d of %d variants from %s", len(loaded), len(engine.Variants()), version), "/_gm/dictionary") + return + } + if err := s.games.SetActiveVersion(c.Request.Context(), version); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Updated", + fmt.Sprintf("dictionary %s installed and activated; new games now use it, games in progress keep their version", version), + "/_gm/dictionary") +} + +// diffStaged computes the word diff of each staged variant against the active +// dictionary, projected into the preview rows. +func (s *Server) diffStaged(staged string, variants []engine.Variant) ([]adminconsole.VariantDiffRow, error) { + active := s.games.ActiveVersion() + rows := make([]adminconsole.VariantDiffRow, 0, len(variants)) + for _, v := range variants { + newFinder, err := engine.OpenFinder(staged, v) + if err != nil { + return nil, err + } + oldFinder, err := s.registry.Finder(v, active) + if err != nil { + _ = newFinder.Close() + return nil, fmt.Errorf("active dictionary %s/%s not resident: %w", v, active, err) + } + diff, err := engine.DiffWords(v, oldFinder, newFinder) + _ = newFinder.Close() + if err != nil { + return nil, err + } + rows = append(rows, diffRow(v, diff)) + } + return rows, nil +} + +// diffRow projects a variant's word diff into its preview row, capping the sample +// lists and flagging a large removal. +func diffRow(v engine.Variant, d engine.WordDiff) adminconsole.VariantDiffRow { + added, addedTrunc := sampleWords(d.Added) + removed, removedTrunc := sampleWords(d.Removed) + return adminconsole.VariantDiffRow{ + Variant: v.String(), + AddedCount: len(d.Added), + RemovedCount: len(d.Removed), + AddedSample: added, + RemovedSample: removed, + AddedTruncated: addedTrunc, + RemovedTruncated: removedTrunc, + LargeRemoval: len(d.Removed) >= largeRemovalThreshold, + } +} + +// sampleWords returns at most previewSampleCap words and whether the list was cut. +func sampleWords(words []string) ([]string, bool) { + if len(words) <= previewSampleCap { + return words, false + } + return words[:previewSampleCap], true +} + +// versionResident reports whether version is loaded for any variant (covering the +// flat seed and every uploaded subdirectory), backing the immutability guard. +func (s *Server) versionResident(version string) bool { + for _, v := range engine.Variants() { + if _, err := s.registry.Finder(v, version); err == nil { + return true + } + } + return false } // consoleApplyChanges marks a variant's pending accepted changes applied in a diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 429a394..640a9dc 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -63,9 +63,10 @@ type Deps struct { // Links drives account linking & merge: the /api/v1/user/link // endpoints. A nil Links disables them. Links *link.Service - // Registry holds the resident dictionaries; the admin console 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 holds the resident dictionaries; the admin console reads its + // versions and installs new ones uploaded through it. DictDir is the dictionary + // directory the console stages uploads in and writes version subdirectories to. + // A nil Registry disables the console. Registry *engine.Registry DictDir string // Connector is the backend's Telegram connector client for operator broadcasts; diff --git a/deploy/.env.example b/deploy/.env.example index 5ae3d1a..1c9bd5d 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -10,7 +10,12 @@ POSTGRES_USER=scrabble POSTGRES_PASSWORD=change-me # required # --- Dictionary ------------------------------------------------------------- -DICT_VERSION=v1.0.0 # scrabble-dictionary release tag (image build-arg) +# scrabble-dictionary release tag baked into the image as the SEED dictionary +# (image build-arg; also labels the resident seed version). After first boot the +# dawg-data volume preserves versions uploaded through the admin console, and the +# active version lives in the DB — so bumping this on a later deploy does NOT change +# a running contour; update the dictionary through /_gm/dictionary instead. +DICT_VERSION=v1.0.0 # --- Logging ---------------------------------------------------------------- LOG_LEVEL=info diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5702f17..ac30987 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -90,6 +90,15 @@ services: # GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the # cgroup quota (the runtime otherwise sees all of the host's cores). GOMAXPROCS: "2" + # The dictionary lives on a named volume seeded from the image on first boot + # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume + # inherits). The admin console writes new version subdirectories here, and the + # volume preserves them — and the versions in-progress games pin — across + # redeploys. Once seeded the volume is not re-seeded, so bumping DICT_VERSION on a + # later deploy has no effect on an existing contour; update via the console + # instead (docs/ARCHITECTURE.md §5). + volumes: + - dawg-data:/opt/dawg # No container healthcheck: the distroless image has no shell/wget. Readiness # is covered by the CI post-deploy probe (GET / through caddy). # R7 starting limits (generous over the R2 ~1-core / <=100 MiB peak); tightened to @@ -366,6 +375,7 @@ networks: volumes: postgres-data: + dawg-data: caddy-data: prometheus-data: tempo-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1d27b84..06f07af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -233,17 +233,32 @@ Key points: The registry holds dictionaries in memory addressed by `(variant, dict_version)`, tracking the latest version per variant, and answers the word-check tool through `Registry.Lookup`. -- **Dictionary versioning — pin per game.** A game records the `dict_version` it - started on and finishes on that version; new games use the latest. Multiple - versions may be resident at once. The boot version loads from the flat - `BACKEND_DICT_DIR`; the admin console **hot-reloads** a new version from a - per-version subdirectory `BACKEND_DICT_DIR//` through - `Registry.LoadAvailable` (only the variants whose DAWG is present there), and a - restart re-loads every resident version via `engine.OpenWithVersions` (the flat - boot version plus each subdirectory). In-flight games keep their pinned version; - new games use the latest. (The solver is published as a versioned module and the - dictionaries ship as a separate versioned **release artifact** from the - `scrabble-dictionary` repo; the runtime contract above is unchanged.) +- **Dictionary versioning — pin per game, update through the console.** A game + records the `dict_version` it started on and finishes on it; new games pin the + **active version**, the single source of truth persisted in the + `dictionary_state` singleton and restored on boot, so an operator's choice + survives a restart. Multiple versions are resident at once. `BACKEND_DICT_DIR` is + a writable named volume seeded from the image on first boot (its nonroot + ownership is inherited from the image, so the runtime can write it); the flat + directory is the seed version, labelled `BACKEND_DICT_VERSION` — set from the + build's `DICT_VERSION`, so the resident label equals the release tag — and each + uploaded version lives in a `BACKEND_DICT_DIR//` subdirectory. The admin + console **updates** a dictionary online (`internal/dictadmin`): an operator + uploads the `scrabble-dawg-vX.Y.Z.tar.gz` release archive, previews the + per-variant words added/removed against the active dictionary (`engine.DiffWords` + enumerates both DAWGs and decodes only the differences), and confirms — the + backend extracts the archive into the version subdirectory (hardened against + path-traversal, symlink and decompression-bomb attacks), loads it via + `Registry.LoadAvailable`, and makes it the active version. Versions are immutable + (re-uploading a resident tag is rejected), so in-flight games keep their pinned + version while new games use the new one. A restart re-loads every resident + version via `engine.OpenWithVersions` (the flat seed plus each subdirectory, + skipping the `.staging/` upload area) and restores the active pointer from + `dictionary_state`. The volume preserves uploaded versions across redeploys; + once seeded it is not re-seeded, so after bootstrap dictionary changes go through + the console rather than a rebuild. (The dictionaries ship as a versioned + **release artifact** from the `scrabble-dictionary` repo; the build's + `DICT_VERSION` selects only the seed.) - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses `scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag** @@ -330,7 +345,7 @@ Key points: 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). + applied once the rebuilt version is installed through the console (§5, §12). ## 7. Robot opponent diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index dd60d06..e75736e 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -183,10 +183,13 @@ renders it; the gateway gates it with HTTP Basic Auth on its public listener and proxies it verbatim. The console lists and inspects **users** (profile, statistics, identities, their games) and **games** (summary + seats), works the **word-complaint review queue** — resolving each as reject / accept-add / accept-remove — and exposes -the **dictionary**: the resident versions per variant, a **hot-reload** of a new -version from `BACKEND_DICT_DIR//`, and the **pending wordlist changes** -derived from accepted complaints (which feed the offline rebuild and are marked -applied after a reload). When a Telegram connector is configured an operator can also +the **dictionary**: the active version new games pin, the resident versions per +variant, an online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz` +release archive, preview the words added and removed per variant against the active +dictionary, then install — which writes the version, loads it and makes it active; +versions are immutable and games in progress keep their own), and the **pending +wordlist changes** derived from accepted complaints (which feed the offline rebuild +and are marked applied after an update). When a Telegram connector is configured an operator can also **message a user** (by their Telegram identity) or **post to the game channel**. State-changing actions are protected by a same-origin check; the console tracks no operator identity. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index afed035..73ac898 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -188,9 +188,13 @@ backend; gateway закрывает её HTTP Basic Auth на публичном один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика, identity, их игры) и **игры** (сводка + места), разбирать **очередь жалоб на слова** — закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**: -резидентные версии по вариантам, **горячая перезагрузка** новой версии из -`BACKEND_DICT_DIR//` и **список ожидающих правок**, выведенный из принятых -жалоб (он питает офлайн-пересборку и отмечается применённым после перезагрузки). Если +активная версия, на которую опираются новые партии, резидентные версии по вариантам, +онлайн-**обновление словаря** (загрузить релизный архив `scrabble-dawg-vX.Y.Z.tar.gz`, +посмотреть предпросмотр добавленных и удалённых слов по каждому варианту относительно +активного словаря, затем установить — версия записывается, загружается и становится +активной; версии неизменяемы, а идущие партии остаются на своей) и **список ожидающих +правок**, выведенный из принятых жалоб (он питает офлайн-пересборку и отмечается +применённым после обновления). Если подключён Telegram-коннектор, оператор также может **написать пользователю** (по его Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия защищены проверкой same-origin; личность оператора не отслеживается. diff --git a/docs/TESTING.md b/docs/TESTING.md index d3012b8..aaa1933 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -91,15 +91,20 @@ tests or touching CI. the profile-update away round-trip) and a `notify`-event constructor round-trip. - **Admin & dictionary ops** — `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 + adds the **dictionary** cases (`LoadAvailable` loads only the present variants, + `OpenWithVersions` scans version subdirectories and skips the staging area, `DiffWords` + decodes the words added/removed between two versions); `backend/internal/dictadmin` + unit-tests the release-archive validation and hardened extraction (path-traversal, + symlink, oversize and entry-count rejection, immutable versions); `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). `ratewatch` has + applied), the **dictionary update flow** (upload a release archive over HTTP → preview → + install → the new version becomes active and resident, persists across a restart, pins new + games while in-progress games keep theirs, and is immutable), the admin **list/count** read + queries, and the **/_gm console over HTTP** (pages render; a resolve POST needs a + same-origin header). `ratewatch` has unit tests (window accumulation, the auto-flag threshold + expiry, the bounded episode map), the account-store **high-rate flag round-trip** (set-once / clear / re-flag) and a console flow in `inttest`: a gateway report auto-flags the account, diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index bce39a6..dd2bdd1 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -149,9 +149,13 @@ func (s *Server) HTTPHandler() http.Handler { // 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). In the deployed contour the - // front caddy owns the /_gm Basic-Auth and Grafana routing; this mount serves - // a non-caddy (local) setup. The per-IP admin limiter class guards it — - // notably a Basic-Auth brute force. + // front caddy owns the /_gm Basic-Auth and Grafana routing and proxies /_gm to + // the backend directly (bypassing this mount); this mount serves a non-caddy + // (local) setup. The per-IP admin limiter class guards it — notably a Basic-Auth + // brute force. Note: the shared maxBodyHandler cap (1 MiB by default) also covers + // this mount, so a dictionary-archive upload through the gateway-fronted console + // needs a larger GATEWAY_MAX_BODY_BYTES; the contour path (caddy → backend) is not + // affected and the backend self-caps the upload. mux.Handle("/_gm/", s.limitAdmin(s.adminProxy)) } else { // With the console disabled here, keep /_gm a 404 so the SPA catch-all below