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
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user