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
+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