feat(admin): online dictionary update — upload archive, preview word diff, install & activate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s

Replace the dictionary hot-reload with an online update flow in the GM console
(/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the
per-variant words added/removed against the active dictionary, and confirms to
install + activate it. Versions are immutable; in-progress games keep their pinned
version while new games use the new one.

- engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder,
  Registry.Finder, DictFiles; OpenWithVersions skips the .staging area.
- dictadmin: hardened release-archive validation + extraction (path-traversal,
  symlink, oversize, entry-count rejection) and staging -> install (atomic rename).
- game: active dictionary version persisted in the dictionary_state singleton
  (single source of truth, restored on boot), concurrency-safe accessor.
- storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned),
  so uploaded versions persist across redeploys; the build's DICT_VERSION labels the
  seed and equals the resident tag (BACKEND_DICT_VERSION).
- docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE.

Tests: engine + dictadmin unit; integration upload->preview->install->activate->
restart->pin->immutability->CSRF.
This commit is contained in:
Ilia Denisov
2026-06-13 23:29:06 +02:00
parent 5ff07da025
commit 0f3671f42d
31 changed files with 1590 additions and 78 deletions
+11 -1
View File
@@ -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"]
+4 -2
View File
@@ -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/<version>/`
(`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
+8
View File
@@ -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)
+2 -1
View File
@@ -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"},
}
@@ -1,21 +1,24 @@
{{define "content" -}}
<h1>Dictionary</h1>
{{with .Data}}
<section class="panel"><h2>Active version</h2>
<p>New games pin <span class="pill">{{.ActiveVersion}}</span>. Games already in progress keep the version they started on.</p>
</section>
<section class="panel"><h2>Resident versions</h2>
<table class="list">
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
<thead><tr><th>Variant</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>
<tr><td>{{.Variant}}</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/&lt;version&gt;/ 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>
<section class="panel"><h2>Update dictionaries</h2>
<p class="note">Upload the release archive <code>scrabble-dawg-vX.Y.Z.tar.gz</code> downloaded from the scrabble-dictionary repository. The next step previews the added and removed words per variant; nothing is installed until you confirm.</p>
<form class="form" method="post" action="/_gm/dictionary/upload" enctype="multipart/form-data">
<label>Release archive <input type="file" name="archive" accept=".gz,.tgz,application/gzip" required></label>
<div><button type="submit">Upload &amp; preview</button></div>
</form>
</section>
<section class="panel"><h2>Pending dictionary changes</h2>
@@ -35,7 +38,7 @@
<option value="erudit_ru">erudit_ru</option>
</select>
</label>
<label>In version <input type="text" name="version" placeholder="v2" required></label>
<label>In version <input type="text" name="version" value="{{.ActiveVersion}}" required></label>
<div><button type="submit">Mark applied</button></div>
</form>
</section>
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Dictionary update — preview</h1>
{{with .Data}}
<section class="panel">
<p>Comparing the uploaded archive against the active version <span class="pill">{{.ActiveVersion}}</span>. Review the changes below, then confirm to install and activate.</p>
<form class="form" method="post" action="/_gm/dictionary/install">
<input type="hidden" name="token" value="{{.Token}}">
<label>Version <input type="text" name="version" value="{{.Version}}" required></label>
<div><button type="submit">Update dictionaries</button></div>
</form>
</section>
{{range .Variants}}
<section class="panel">
<h2>{{.Variant}}</h2>
<p><strong>{{.AddedCount}}</strong> added, <strong>{{.RemovedCount}}</strong> removed.
{{if .LargeRemoval}}<span class="warn">Large removal — double-check this is expected before updating.</span>{{end}}</p>
<h3>Added{{if .AddedTruncated}} (showing first {{len .AddedSample}}){{end}}</h3>
<p>{{range .AddedSample}}<code>{{.}}</code> {{else}}<span class="note">none</span>{{end}}</p>
{{if .AddedTruncated}}<p class="note">… and more; the full list is not shown.</p>{{end}}
<h3>Removed{{if .RemovedTruncated}} (showing first {{len .RemovedSample}}){{end}}</h3>
<p>{{range .RemovedSample}}<code>{{.}}</code> {{else}}<span class="note">none</span>{{end}}</p>
{{if .RemovedTruncated}}<p class="note">… and more; the full list is not shown.</p>{{end}}
</section>
{{end}}
<p><a href="/_gm/dictionary">Cancel</a></p>
{{end}}
{{- end}}
+36 -5
View File
@@ -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
+262
View File
@@ -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 <dir>/.staging/<token>, 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 <dir>/<version> 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 <dir>/<version> 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
}
@@ -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)
}
}
}
+134
View File
@@ -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
}
+134
View File
@@ -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")
}
}
+4 -1
View File
@@ -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 {
+21
View File
@@ -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) {
+74 -10
View File
@@ -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 (24), 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)
+35
View File
@@ -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) {
+1 -1
View File
@@ -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
@@ -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()
}
@@ -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
}
@@ -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,
}
}
@@ -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)
@@ -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).
+162 -17
View File
@@ -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
+4 -3
View File
@@ -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;