Merge pull request 'feat(admin): online dictionary update — upload archive, preview word diff, install & activate' (#53) from feature/dict-admin-online-upload into development
This commit was merged in pull request #53.
This commit is contained in:
@@ -27,6 +27,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
|||||||
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
|
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
|
||||||
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
|
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
|
||||||
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** |
|
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** |
|
||||||
|
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
|
||||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
|
|||||||
+11
-1
@@ -37,7 +37,17 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/ba
|
|||||||
|
|
||||||
# --- runtime -----------------------------------------------------------------
|
# --- runtime -----------------------------------------------------------------
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
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=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_DIR=/opt/dawg
|
||||||
|
ENV BACKEND_DICT_VERSION=${DICT_VERSION}
|
||||||
ENTRYPOINT ["/usr/local/bin/backend"]
|
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
|
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
||||||
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||||||
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
||||||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz` release
|
||||||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
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
|
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||||||
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
|
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
|
||||||
holds the language tag of the bot a Telegram
|
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 := account.NewStore(db)
|
||||||
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
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.SetNotifier(hub)
|
||||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
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"},
|
{"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"},
|
{"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"},
|
{"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"},
|
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
||||||
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
<h1>Dictionary</h1>
|
<h1>Dictionary</h1>
|
||||||
{{with .Data}}
|
{{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>
|
<section class="panel"><h2>Resident versions</h2>
|
||||||
<table class="list">
|
<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>
|
<tbody>
|
||||||
{{range .Variants}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Hot-reload a version</h2>
|
<section class="panel"><h2>Update dictionaries</h2>
|
||||||
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.</p>
|
<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/reload">
|
<form class="form" method="post" action="/_gm/dictionary/upload" enctype="multipart/form-data">
|
||||||
<label>Version <input type="text" name="version" placeholder="v2" required></label>
|
<label>Release archive <input type="file" name="archive" accept=".gz,.tgz,application/gzip" required></label>
|
||||||
<div><button type="submit">Reload</button></div>
|
<div><button type="submit">Upload & preview</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Pending dictionary changes</h2>
|
<section class="panel"><h2>Pending dictionary changes</h2>
|
||||||
@@ -35,7 +38,7 @@
|
|||||||
<option value="erudit_ru">erudit_ru</option>
|
<option value="erudit_ru">erudit_ru</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<div><button type="submit">Mark applied</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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
|
ActiveGames int
|
||||||
OpenComplaints int
|
OpenComplaints int
|
||||||
PendingChanges 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.
|
// UsersView is the paginated account list.
|
||||||
@@ -237,11 +240,13 @@ type ComplaintDetailView struct {
|
|||||||
Resolved bool
|
Resolved bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DictionaryView lists the resident versions per variant and the pending
|
// DictionaryView lists the resident versions per variant, the active version new
|
||||||
// wordlist changes from accepted complaints.
|
// games pin, and the pending wordlist changes from accepted complaints.
|
||||||
type DictionaryView struct {
|
type DictionaryView struct {
|
||||||
Variants []VariantVersions
|
// ActiveVersion is the dictionary version new games pin; the update form sets it.
|
||||||
Changes []DictChangeRow
|
ActiveVersion string
|
||||||
|
Variants []VariantVersions
|
||||||
|
Changes []DictChangeRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// DictChangeRow is one pending wordlist edit.
|
// DictChangeRow is one pending wordlist edit.
|
||||||
@@ -252,6 +257,32 @@ type DictChangeRow struct {
|
|||||||
ResolvedAt string
|
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.
|
// BroadcastView is the operator-broadcast form page.
|
||||||
type BroadcastView struct {
|
type BroadcastView struct {
|
||||||
ConnectorEnabled bool
|
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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
dawg "github.com/iliadenisov/dafsa"
|
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)
|
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
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
|
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
|
||||||
// already resident, moves the latest pointer and keeps the earlier version.
|
// already resident, moves the latest pointer and keeps the earlier version.
|
||||||
func TestReloadRegistersNewVersion(t *testing.T) {
|
func TestReloadRegistersNewVersion(t *testing.T) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -30,12 +31,16 @@ type Service struct {
|
|||||||
registry *engine.Registry
|
registry *engine.Registry
|
||||||
cache *gameCache
|
cache *gameCache
|
||||||
locks *keyedMutex
|
locks *keyedMutex
|
||||||
version string
|
// verMu guards version, the dictionary version new games pin. It is read on
|
||||||
clock func() time.Time
|
// the game-create path and rewritten when an operator activates a new version
|
||||||
rng func() int64
|
// through the admin console, so access is serialised.
|
||||||
pub notify.Publisher
|
verMu sync.RWMutex
|
||||||
metrics *gameMetrics
|
version string
|
||||||
log *zap.Logger
|
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;
|
// 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
|
// 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
|
// (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
|
// 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 {
|
if seed == 0 {
|
||||||
seed = svc.rng()
|
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{
|
g, err := engine.New(svc.registry, engine.Options{
|
||||||
Variant: params.Variant,
|
Variant: params.Variant,
|
||||||
Version: svc.version,
|
Version: version,
|
||||||
Players: len(params.Seats),
|
Players: len(params.Seats),
|
||||||
Seed: seed,
|
Seed: seed,
|
||||||
DropoutTiles: params.DropoutTiles,
|
DropoutTiles: params.DropoutTiles,
|
||||||
@@ -128,7 +192,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
|||||||
ins := gameInsert{
|
ins := gameInsert{
|
||||||
id: id,
|
id: id,
|
||||||
variant: params.Variant.String(),
|
variant: params.Variant.String(),
|
||||||
dictVersion: svc.version,
|
dictVersion: version,
|
||||||
seed: seed,
|
seed: seed,
|
||||||
players: len(params.Seats),
|
players: len(params.Seats),
|
||||||
turnTimeoutSecs: int(timeout / time.Second),
|
turnTimeoutSecs: int(timeout / time.Second),
|
||||||
@@ -180,7 +244,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
|||||||
ins := gameInsert{
|
ins := gameInsert{
|
||||||
id: id,
|
id: id,
|
||||||
variant: params.Variant.String(),
|
variant: params.Variant.String(),
|
||||||
dictVersion: svc.version,
|
dictVersion: svc.activeVersion(),
|
||||||
seed: seed,
|
seed: seed,
|
||||||
players: 2,
|
players: 2,
|
||||||
turnTimeoutSecs: int(timeout / time.Second),
|
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
|
// 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.
|
// them from DictionaryChanges. It returns the number of changes marked.
|
||||||
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
|
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
|
||||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
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
|
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
|
// CountComplaints returns the number of complaints, optionally restricted to a
|
||||||
// status, for the admin queue pager and the dashboard counts.
|
// status, for the admin queue pager and the dashboard counts.
|
||||||
func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) {
|
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
|
// complaint: Add reports whether Word should be added (DispositionAcceptAdd) or
|
||||||
// removed (DispositionAcceptRemove) for Variant. The admin console lists the
|
// removed (DispositionAcceptRemove) for Variant. The admin console lists the
|
||||||
// pending changes as the input to the offline DAWG rebuild; once a rebuilt
|
// 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 {
|
type DictionaryChange struct {
|
||||||
ComplaintID uuid.UUID
|
ComplaintID uuid.UUID
|
||||||
Variant engine.Variant
|
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)
|
Blocks = Blocks.FromSchema(schema)
|
||||||
ChatMessages = ChatMessages.FromSchema(schema)
|
ChatMessages = ChatMessages.FromSchema(schema)
|
||||||
Complaints = Complaints.FromSchema(schema)
|
Complaints = Complaints.FromSchema(schema)
|
||||||
|
DictionaryState = DictionaryState.FromSchema(schema)
|
||||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||||
FriendCodes = FriendCodes.FromSchema(schema)
|
FriendCodes = FriendCodes.FromSchema(schema)
|
||||||
Friendships = Friendships.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
|
-- 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
|
-- 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
|
-- 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 (
|
CREATE TABLE complaints (
|
||||||
complaint_id uuid PRIMARY KEY,
|
complaint_id uuid PRIMARY KEY,
|
||||||
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
|
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
|
||||||
@@ -180,6 +180,19 @@ CREATE TABLE complaints (
|
|||||||
);
|
);
|
||||||
CREATE INDEX complaints_status_idx ON complaints (status);
|
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.
|
-- 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
|
-- 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).
|
-- best single move score (folding in every word the move formed and the all-tiles bonus).
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
|
"scrabble/backend/internal/dictadmin"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
@@ -60,7 +60,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.GET("/messages", s.consoleMessages)
|
gm.GET("/messages", s.consoleMessages)
|
||||||
gm.GET("/messages.csv", s.consoleMessagesCSV)
|
gm.GET("/messages.csv", s.consoleMessagesCSV)
|
||||||
gm.GET("/dictionary", s.consoleDictionary)
|
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.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
||||||
gm.GET("/broadcast", s.consoleBroadcast)
|
gm.GET("/broadcast", s.consoleBroadcast)
|
||||||
gm.POST("/broadcast", s.consolePostBroadcast)
|
gm.POST("/broadcast", s.consolePostBroadcast)
|
||||||
@@ -70,7 +71,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
// dictionary versions.
|
// dictionary versions.
|
||||||
func (s *Server) consoleDashboard(c *gin.Context) {
|
func (s *Server) consoleDashboard(c *gin.Context) {
|
||||||
ctx := c.Request.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.Accounts, _ = s.accounts.CountAccounts(ctx)
|
||||||
view.Games, _ = s.games.CountGames(ctx, "")
|
view.Games, _ = s.games.CountGames(ctx, "")
|
||||||
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
|
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.
|
// consoleDictionary renders the resident versions and the pending wordlist changes.
|
||||||
func (s *Server) consoleDictionary(c *gin.Context) {
|
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 {
|
if changes, err := s.games.DictionaryChanges(c.Request.Context()); err == nil {
|
||||||
for _, ch := range changes {
|
for _, ch := range changes {
|
||||||
action := "remove"
|
action := "remove"
|
||||||
@@ -486,28 +487,172 @@ func (s *Server) consoleDictionary(c *gin.Context) {
|
|||||||
s.renderConsole(c, "dictionary", "dictionary", "Dictionary", view)
|
s.renderConsole(c, "dictionary", "dictionary", "Dictionary", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleReloadDictionary hot-loads a dictionary version from its subdirectory.
|
// previewSampleCap bounds how many added/removed words the update preview lists per
|
||||||
func (s *Server) consoleReloadDictionary(c *gin.Context) {
|
// variant; the full totals are always shown.
|
||||||
version := trimForm(c, "version")
|
const previewSampleCap = 200
|
||||||
if version == "" {
|
|
||||||
s.renderConsoleMessage(c, "Reload failed", "a version is required", "/_gm/dictionary")
|
// 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
|
return
|
||||||
}
|
}
|
||||||
dir := filepath.Join(s.dictDir, version)
|
defer func() { _ = file.Close() }()
|
||||||
loaded, err := s.registry.LoadAvailable(dir, version)
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(loaded) == 0 {
|
if len(variants) != len(engine.Variants()) {
|
||||||
s.renderConsoleMessage(c, "Nothing loaded", "no dictionary files found in "+dir, "/_gm/dictionary")
|
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
|
return
|
||||||
}
|
}
|
||||||
names := make([]string, len(loaded))
|
|
||||||
for i, v := range loaded {
|
staged, err := mgr.StagedDir(token)
|
||||||
names[i] = v.String()
|
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
|
// 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
|
// Links drives account linking & merge: the /api/v1/user/link
|
||||||
// endpoints. A nil Links disables them.
|
// endpoints. A nil Links disables them.
|
||||||
Links *link.Service
|
Links *link.Service
|
||||||
// Registry holds the resident dictionaries; the admin console reads
|
// Registry holds the resident dictionaries; the admin console reads its
|
||||||
// its versions and hot-reloads new ones. DictDir is the dictionary directory a
|
// versions and installs new ones uploaded through it. DictDir is the dictionary
|
||||||
// reload reads a version subdirectory from. A nil Registry disables the console.
|
// directory the console stages uploads in and writes version subdirectories to.
|
||||||
|
// A nil Registry disables the console.
|
||||||
Registry *engine.Registry
|
Registry *engine.Registry
|
||||||
DictDir string
|
DictDir string
|
||||||
// Connector is the backend's Telegram connector client for operator broadcasts;
|
// Connector is the backend's Telegram connector client for operator broadcasts;
|
||||||
|
|||||||
+6
-1
@@ -10,7 +10,12 @@ POSTGRES_USER=scrabble
|
|||||||
POSTGRES_PASSWORD=change-me # required
|
POSTGRES_PASSWORD=change-me # required
|
||||||
|
|
||||||
# --- Dictionary -------------------------------------------------------------
|
# --- Dictionary -------------------------------------------------------------
|
||||||
DICT_VERSION=v1.0.0 # scrabble-dictionary release tag (image build-arg)
|
# scrabble-dictionary release tag baked into the image as the SEED dictionary
|
||||||
|
# (image build-arg; also labels the resident seed version). After first boot the
|
||||||
|
# dawg-data volume preserves versions uploaded through the admin console, and the
|
||||||
|
# active version lives in the DB — so bumping this on a later deploy does NOT change
|
||||||
|
# a running contour; update the dictionary through /_gm/dictionary instead.
|
||||||
|
DICT_VERSION=v1.0.0
|
||||||
|
|
||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ services:
|
|||||||
# GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the
|
# GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the
|
||||||
# cgroup quota (the runtime otherwise sees all of the host's cores).
|
# cgroup quota (the runtime otherwise sees all of the host's cores).
|
||||||
GOMAXPROCS: "2"
|
GOMAXPROCS: "2"
|
||||||
|
# The dictionary lives on a named volume seeded from the image on first boot
|
||||||
|
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
||||||
|
# inherits). The admin console writes new version subdirectories here, and the
|
||||||
|
# volume preserves them — and the versions in-progress games pin — across
|
||||||
|
# redeploys. Once seeded the volume is not re-seeded, so bumping DICT_VERSION on a
|
||||||
|
# later deploy has no effect on an existing contour; update via the console
|
||||||
|
# instead (docs/ARCHITECTURE.md §5).
|
||||||
|
volumes:
|
||||||
|
- dawg-data:/opt/dawg
|
||||||
# No container healthcheck: the distroless image has no shell/wget. Readiness
|
# No container healthcheck: the distroless image has no shell/wget. Readiness
|
||||||
# is covered by the CI post-deploy probe (GET / through caddy).
|
# is covered by the CI post-deploy probe (GET / through caddy).
|
||||||
# R7 starting limits (generous over the R2 ~1-core / <=100 MiB peak); tightened to
|
# R7 starting limits (generous over the R2 ~1-core / <=100 MiB peak); tightened to
|
||||||
@@ -366,6 +375,7 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
dawg-data:
|
||||||
caddy-data:
|
caddy-data:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
tempo-data:
|
tempo-data:
|
||||||
|
|||||||
+27
-12
@@ -233,17 +233,32 @@ Key points:
|
|||||||
The registry holds dictionaries in memory addressed by `(variant,
|
The registry holds dictionaries in memory addressed by `(variant,
|
||||||
dict_version)`, tracking the latest version per variant, and answers the
|
dict_version)`, tracking the latest version per variant, and answers the
|
||||||
word-check tool through `Registry.Lookup`.
|
word-check tool through `Registry.Lookup`.
|
||||||
- **Dictionary versioning — pin per game.** A game records the `dict_version` it
|
- **Dictionary versioning — pin per game, update through the console.** A game
|
||||||
started on and finishes on that version; new games use the latest. Multiple
|
records the `dict_version` it started on and finishes on it; new games pin the
|
||||||
versions may be resident at once. The boot version loads from the flat
|
**active version**, the single source of truth persisted in the
|
||||||
`BACKEND_DICT_DIR`; the admin console **hot-reloads** a new version from a
|
`dictionary_state` singleton and restored on boot, so an operator's choice
|
||||||
per-version subdirectory `BACKEND_DICT_DIR/<version>/` through
|
survives a restart. Multiple versions are resident at once. `BACKEND_DICT_DIR` is
|
||||||
`Registry.LoadAvailable` (only the variants whose DAWG is present there), and a
|
a writable named volume seeded from the image on first boot (its nonroot
|
||||||
restart re-loads every resident version via `engine.OpenWithVersions` (the flat
|
ownership is inherited from the image, so the runtime can write it); the flat
|
||||||
boot version plus each subdirectory). In-flight games keep their pinned version;
|
directory is the seed version, labelled `BACKEND_DICT_VERSION` — set from the
|
||||||
new games use the latest. (The solver is published as a versioned module and the
|
build's `DICT_VERSION`, so the resident label equals the release tag — and each
|
||||||
dictionaries ship as a separate versioned **release artifact** from the
|
uploaded version lives in a `BACKEND_DICT_DIR/<version>/` subdirectory. The admin
|
||||||
`scrabble-dictionary` repo; the runtime contract above is unchanged.)
|
console **updates** a dictionary online (`internal/dictadmin`): an operator
|
||||||
|
uploads the `scrabble-dawg-vX.Y.Z.tar.gz` release archive, previews the
|
||||||
|
per-variant words added/removed against the active dictionary (`engine.DiffWords`
|
||||||
|
enumerates both DAWGs and decodes only the differences), and confirms — the
|
||||||
|
backend extracts the archive into the version subdirectory (hardened against
|
||||||
|
path-traversal, symlink and decompression-bomb attacks), loads it via
|
||||||
|
`Registry.LoadAvailable`, and makes it the active version. Versions are immutable
|
||||||
|
(re-uploading a resident tag is rejected), so in-flight games keep their pinned
|
||||||
|
version while new games use the new one. A restart re-loads every resident
|
||||||
|
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
|
||||||
|
skipping the `.staging/` upload area) and restores the active pointer from
|
||||||
|
`dictionary_state`. The volume preserves uploaded versions across redeploys;
|
||||||
|
once seeded it is not re-seeded, so after bootstrap dictionary changes go through
|
||||||
|
the console rather than a rebuild. (The dictionaries ship as a versioned
|
||||||
|
**release artifact** from the `scrabble-dictionary` repo; the build's
|
||||||
|
`DICT_VERSION` selects only the seed.)
|
||||||
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
||||||
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
||||||
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
||||||
@@ -330,7 +345,7 @@ Key points:
|
|||||||
review queue. An operator resolves it (`open → resolved`) with a **disposition** —
|
review queue. An operator resolves it (`open → resolved`) with a **disposition** —
|
||||||
reject, accept-add or accept-remove; the accepted ones form a derived
|
reject, accept-add or accept-remove; the accepted ones form a derived
|
||||||
**pending-changes** list that feeds the offline dictionary rebuild and is marked
|
**pending-changes** list that feeds the offline dictionary rebuild and is marked
|
||||||
applied once the rebuilt version is hot-reloaded (§5, §12).
|
applied once the rebuilt version is installed through the console (§5, §12).
|
||||||
|
|
||||||
## 7. Robot opponent
|
## 7. Robot opponent
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -183,10 +183,13 @@ renders it; the gateway gates it with HTTP Basic Auth on its public listener and
|
|||||||
proxies it verbatim. The console lists and inspects **users** (profile, statistics,
|
proxies it verbatim. The console lists and inspects **users** (profile, statistics,
|
||||||
identities, their games) and **games** (summary + seats), works the **word-complaint
|
identities, their games) and **games** (summary + seats), works the **word-complaint
|
||||||
review queue** — resolving each as reject / accept-add / accept-remove — and exposes
|
review queue** — resolving each as reject / accept-add / accept-remove — and exposes
|
||||||
the **dictionary**: the resident versions per variant, a **hot-reload** of a new
|
the **dictionary**: the active version new games pin, the resident versions per
|
||||||
version from `BACKEND_DICT_DIR/<version>/`, and the **pending wordlist changes**
|
variant, an online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz`
|
||||||
derived from accepted complaints (which feed the offline rebuild and are marked
|
release archive, preview the words added and removed per variant against the active
|
||||||
applied after a reload). When a Telegram connector is configured an operator can also
|
dictionary, then install — which writes the version, loads it and makes it active;
|
||||||
|
versions are immutable and games in progress keep their own), and the **pending
|
||||||
|
wordlist changes** derived from accepted complaints (which feed the offline rebuild
|
||||||
|
and are marked applied after an update). When a Telegram connector is configured an operator can also
|
||||||
**message a user** (by their Telegram identity) or **post to the game channel**.
|
**message a user** (by their Telegram identity) or **post to the game channel**.
|
||||||
State-changing actions are protected by a same-origin check; the console tracks no
|
State-changing actions are protected by a same-origin check; the console tracks no
|
||||||
operator identity.
|
operator identity.
|
||||||
|
|||||||
@@ -188,9 +188,13 @@ backend; gateway закрывает её HTTP Basic Auth на публичном
|
|||||||
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
|
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
|
||||||
identity, их игры) и **игры** (сводка + места), разбирать **очередь жалоб на слова** —
|
identity, их игры) и **игры** (сводка + места), разбирать **очередь жалоб на слова** —
|
||||||
закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**:
|
закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**:
|
||||||
резидентные версии по вариантам, **горячая перезагрузка** новой версии из
|
активная версия, на которую опираются новые партии, резидентные версии по вариантам,
|
||||||
`BACKEND_DICT_DIR/<version>/` и **список ожидающих правок**, выведенный из принятых
|
онлайн-**обновление словаря** (загрузить релизный архив `scrabble-dawg-vX.Y.Z.tar.gz`,
|
||||||
жалоб (он питает офлайн-пересборку и отмечается применённым после перезагрузки). Если
|
посмотреть предпросмотр добавленных и удалённых слов по каждому варианту относительно
|
||||||
|
активного словаря, затем установить — версия записывается, загружается и становится
|
||||||
|
активной; версии неизменяемы, а идущие партии остаются на своей) и **список ожидающих
|
||||||
|
правок**, выведенный из принятых жалоб (он питает офлайн-пересборку и отмечается
|
||||||
|
применённым после обновления). Если
|
||||||
подключён Telegram-коннектор, оператор также может **написать пользователю** (по его
|
подключён Telegram-коннектор, оператор также может **написать пользователю** (по его
|
||||||
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
|
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
|
||||||
защищены проверкой same-origin; личность оператора не отслеживается.
|
защищены проверкой same-origin; личность оператора не отслеживается.
|
||||||
|
|||||||
+10
-5
@@ -91,15 +91,20 @@ tests or touching CI.
|
|||||||
the profile-update away round-trip) and a `notify`-event constructor round-trip.
|
the profile-update away round-trip) and a `notify`-event constructor round-trip.
|
||||||
- **Admin & dictionary ops** — `backend/internal/adminconsole` unit-tests
|
- **Admin & dictionary ops** — `backend/internal/adminconsole` unit-tests
|
||||||
the template renderer over every page plus the embedded asset; `backend/internal/engine`
|
the template renderer over every page plus the embedded asset; `backend/internal/engine`
|
||||||
adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present
|
adds the **dictionary** cases (`LoadAvailable` loads only the present variants,
|
||||||
variants, `OpenWithVersions` scans version subdirectories, a reload registers a new
|
`OpenWithVersions` scans version subdirectories and skips the staging area, `DiffWords`
|
||||||
version and moves "latest"); `backend/internal/server` unit-tests the console's
|
decodes the words added/removed between two versions); `backend/internal/dictadmin`
|
||||||
|
unit-tests the release-archive validation and hardened extraction (path-traversal,
|
||||||
|
symlink, oversize and entry-count rejection, immutable versions); `backend/internal/server` unit-tests the console's
|
||||||
**same-origin** CSRF guard; the gateway adds the **verbatim `/_gm` Basic-Auth proxy**
|
**same-origin** CSRF guard; the gateway adds the **verbatim `/_gm` Basic-Auth proxy**
|
||||||
(401 / forward, path preserved) and the h2c **console mount** (routed when configured,
|
(401 / forward, path preserved) and the h2c **console mount** (routed when configured,
|
||||||
404 when not). Postgres-backed `inttest` drives the **complaint resolution →
|
404 when not). Postgres-backed `inttest` drives the **complaint resolution →
|
||||||
dictionary-change pipeline** (file → resolve with a disposition → pending change → mark
|
dictionary-change pipeline** (file → resolve with a disposition → pending change → mark
|
||||||
applied), the admin **list/count** read queries, and the **/_gm console over HTTP**
|
applied), the **dictionary update flow** (upload a release archive over HTTP → preview →
|
||||||
(pages render; a resolve POST needs a same-origin header). `ratewatch` has
|
install → the new version becomes active and resident, persists across a restart, pins new
|
||||||
|
games while in-progress games keep theirs, and is immutable), the admin **list/count** read
|
||||||
|
queries, and the **/_gm console over HTTP** (pages render; a resolve POST needs a
|
||||||
|
same-origin header). `ratewatch` has
|
||||||
unit tests (window accumulation, the auto-flag threshold + expiry, the bounded
|
unit tests (window accumulation, the auto-flag threshold + expiry, the bounded
|
||||||
episode map), the account-store **high-rate flag round-trip** (set-once / clear /
|
episode map), the account-store **high-rate flag round-trip** (set-once / clear /
|
||||||
re-flag) and a console flow in `inttest`: a gateway report auto-flags the account,
|
re-flag) and a console flow in `inttest`: a gateway report auto-flags the account,
|
||||||
|
|||||||
@@ -149,9 +149,13 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
// The admin console (backend /_gm) is served on the public listener behind
|
// The admin console (backend /_gm) is served on the public listener behind
|
||||||
// the proxy's Basic-Auth, mounted below the h2c wrap so the Connect edge keeps
|
// the proxy's Basic-Auth, mounted below the h2c wrap so the Connect edge keeps
|
||||||
// working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the
|
// working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the
|
||||||
// front caddy owns the /_gm Basic-Auth and Grafana routing; this mount serves
|
// front caddy owns the /_gm Basic-Auth and Grafana routing and proxies /_gm to
|
||||||
// a non-caddy (local) setup. The per-IP admin limiter class guards it —
|
// the backend directly (bypassing this mount); this mount serves a non-caddy
|
||||||
// notably a Basic-Auth brute force.
|
// (local) setup. The per-IP admin limiter class guards it — notably a Basic-Auth
|
||||||
|
// brute force. Note: the shared maxBodyHandler cap (1 MiB by default) also covers
|
||||||
|
// this mount, so a dictionary-archive upload through the gateway-fronted console
|
||||||
|
// needs a larger GATEWAY_MAX_BODY_BYTES; the contour path (caddy → backend) is not
|
||||||
|
// affected and the backend self-caps the upload.
|
||||||
mux.Handle("/_gm/", s.limitAdmin(s.adminProxy))
|
mux.Handle("/_gm/", s.limitAdmin(s.adminProxy))
|
||||||
} else {
|
} else {
|
||||||
// With the console disabled here, keep /_gm a 404 so the SPA catch-all below
|
// With the console disabled here, keep /_gm a 404 so the SPA catch-all below
|
||||||
|
|||||||
Reference in New Issue
Block a user