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
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.
263 lines
8.4 KiB
Go
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
|
|
}
|