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:
+11
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/<version>/ 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 & 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}}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (2–4), 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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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