Files
scrabble-game/backend/internal/dictadmin/dictadmin.go
T
Ilia Denisov 0f3671f42d
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
feat(admin): online dictionary update — upload archive, preview word diff, install & activate
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.
2026-06-13 23:29:06 +02:00

263 lines
8.4 KiB
Go

// 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
}