feat: Go backend production cutover — website layer, ingest forwarding, alerts, live fixes

Completes the Go backend so it can fully replace Python in production:

tracker-go website layer (serves the unchanged frontend):
- static file serving + SPA fallback + /icons (website.go)
- login/logout with itsdangerous cookie ISSUING (bcrypt, Python-interop) and the
  /me handler (auth.go issueSessionCookie + website.go)
- admin user CRUD (website_admin.go) and the issue-board write side (website_issues.go)
- request-scoped user context + requireAdmin (auth.go)

cutover ingest (gated off during the parallel run, required for a clean cutover):
- inventory forwarding: full_inventory -> /process-inventory, inventory_delta ->
  item POST/DELETE, per-character serialized, fire-and-forget (inventory_forward.go)
- death/idle Discord alerts via DISCORD_ACLOG_WEBHOOK (aclog.go)
- SKIP_SCHEMA_INIT so write mode against the prod DBs runs no DDL (tracker-go + inventory-go)

two bugs found live and fixed:
- coerceNum: the plugin sends kills_per_hour/deaths/total_deaths/prismatic_taper_count
  as STRINGS; pydantic coerced them, Go's number helpers wrote null/0 (reads.go/ingest.go)
- telemetry is broadcast TYPELESS so the browser ignores it and uses the /live poll;
  broadcasting it typed flapped the per-player counters 0<->value (ingest.go stripType)

docker-compose.cutover.yml: reversible override flipping the Go services to write
mode against the production DBs and repointing the Discord bot at the Go /ws/live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 19:46:40 +02:00
parent 776076b981
commit 5ade47dc64
13 changed files with 1074 additions and 66 deletions

View file

@ -0,0 +1,145 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// trimFloat formats a vitae value without a trailing ".0" for whole numbers.
func trimFloat(f float64) string {
if f == float64(int64(f)) {
return strconv.FormatInt(int64(f), 10)
}
return strconv.FormatFloat(f, 'f', -1, 64)
}
// aclogPoster posts death + idle alerts to the #aclog Discord webhook, porting
// main.py's _send_discord_aclog / death detection / _idle_detection_loop. nil
// when DISCORD_ACLOG_WEBHOOK is unset (or in shadow mode).
type aclogPoster struct {
webhook string
client *http.Client
log *slog.Logger
mu sync.Mutex
deathAlerted map[string]time.Time // char -> last death alert (max 1 / 5min)
idleSince map[string]time.Time // char -> first detected idle
idleAlerted map[string]bool // char -> already alerted this idle period
}
func newACLogPoster(webhook string, log *slog.Logger) *aclogPoster {
return &aclogPoster{
webhook: webhook,
client: &http.Client{Timeout: 5 * time.Second},
log: log,
deathAlerted: map[string]time.Time{},
idleSince: map[string]time.Time{},
idleAlerted: map[string]bool{},
}
}
func (a *aclogPoster) post(message string) {
if a == nil || a.webhook == "" {
return
}
body, _ := json.Marshal(map[string]any{"content": message})
resp, err := a.client.Post(a.webhook, "application/json", bytes.NewReader(body))
if err != nil {
a.log.Debug("discord webhook failed", "err", err)
return
}
drain(resp)
}
// maybeDeath fires a death alert when vitae crosses 0 -> >0, capped at 1 per
// 5 minutes per character (main.py:3419).
func (a *aclogPoster) maybeDeath(name string, vitae float64) {
if a == nil || a.webhook == "" {
return
}
a.mu.Lock()
last, ok := a.deathAlerted[name]
if ok && time.Since(last) <= 5*time.Minute {
a.mu.Unlock()
return
}
a.deathAlerted[name] = time.Now()
a.mu.Unlock()
go a.post(fmt.Sprintf("☠️ **%s** died! (vitae: %s%%)", name, trimFloat(vitae)))
}
// runIdleLoop polls online players every 60s and alerts on idle (main.py:2694).
func (a *aclogPoster) runIdleLoop(ctx context.Context, pool *pgxpool.Pool) {
if a == nil || a.webhook == "" {
return
}
select {
case <-time.After(30 * time.Second): // let telemetry arrive first
case <-ctx.Done():
return
}
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for {
a.checkIdleOnce(ctx, pool)
select {
case <-t.C:
case <-ctx.Done():
return
}
}
}
func (a *aclogPoster) checkIdleOnce(ctx context.Context, pool *pgxpool.Pool) {
qctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
rows, err := pool.Query(qctx, `
SELECT DISTINCT ON (character_name) character_name, COALESCE(vt_state,''), COALESCE(kills_per_hour, 0)
FROM telemetry_events
WHERE COALESCE(received_at, timestamp) > now() - interval '30 seconds'
ORDER BY character_name, timestamp DESC`)
if err != nil {
a.log.Debug("idle query failed", "err", err)
return
}
defer rows.Close()
now := time.Now()
a.mu.Lock()
defer a.mu.Unlock()
for rows.Next() {
var name, vtState string
var kph float64
if rows.Scan(&name, &vtState, &kph) != nil {
continue
}
s := strings.ToLower(vtState)
kphi := int(kph)
isIdle := s == "default" || s == "idle" || s == "" || ((s == "combat" || s == "hunt") && kphi == 0)
if isIdle {
if _, seen := a.idleSince[name]; !seen {
a.idleSince[name] = now
} else if !a.idleAlerted[name] && now.Sub(a.idleSince[name]) >= 5*time.Minute {
a.idleAlerted[name] = true
idleMins := int(now.Sub(a.idleSince[name]).Minutes())
stateText := vtState
if stateText == "" {
stateText = "idle"
}
go a.post(fmt.Sprintf("⚠️ **%s** appears idle for %dmin (state: %s, KPH: %d)", name, idleMins, stateText, kphi))
}
} else {
delete(a.idleAlerted, name)
delete(a.idleSince, name)
}
}
}

View file

@ -3,6 +3,7 @@ package main
import (
"bytes"
"compress/zlib"
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
@ -14,6 +15,29 @@ import (
"time"
)
type userCtxKey struct{}
func withUser(ctx context.Context, u *sessionUser) context.Context {
return context.WithValue(ctx, userCtxKey{}, u)
}
// currentUser returns the authenticated user for the request, or nil (e.g.
// internal-trust loopback requests carry no user identity).
func currentUser(r *http.Request) *sessionUser {
u, _ := r.Context().Value(userCtxKey{}).(*sessionUser)
return u
}
// requireAdmin writes 403 and returns false unless the request is an admin
// (main.py _require_admin).
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if u := currentUser(r); u != nil && u.IsAdmin {
return true
}
writeJSON(w, http.StatusForbidden, map[string]any{"detail": "Admin access required"})
return false
}
// Session-cookie verification compatible with the Python service's
// itsdangerous URLSafeTimedSerializer(SECRET_KEY) (itsdangerous 2.2):
// - HMAC-SHA1 signature
@ -93,6 +117,50 @@ func verifySessionCookie(secretKey, token string) *sessionUser {
return &sessionUser{Username: data.U, IsAdmin: data.A}
}
// issueSessionCookie produces an itsdangerous URLSafeTimedSerializer token
// compatible with the Python service (so Go-issued cookies verify on Python and
// vice-versa). Inverse of verifySessionCookie.
func issueSessionCookie(secretKey string, u sessionUser) string {
payload, _ := json.Marshal(struct {
U string `json:"u"`
A bool `json:"a"`
}{u.Username, u.IsAdmin})
payloadPart := encodeItsdangerousPayload(payload)
tsPart := base64.RawURLEncoding.EncodeToString(int64ToBytes(time.Now().Unix()))
signed := payloadPart + "." + tsPart
mac := hmac.New(sha1.New, deriveSignerKey(secretKey))
mac.Write([]byte(signed))
return signed + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// encodeItsdangerousPayload mirrors URLSafeSerializerBase.dump_payload: zlib-
// compress only when it actually saves more than one byte (it won't for our tiny
// payload), then urlsafe-base64 (no pad), with a "." marker if compressed.
func encodeItsdangerousPayload(jsonBytes []byte) string {
var buf bytes.Buffer
zw := zlib.NewWriter(&buf)
_, _ = zw.Write(jsonBytes)
_ = zw.Close()
if compressed := buf.Bytes(); len(compressed) < len(jsonBytes)-1 {
return "." + base64.RawURLEncoding.EncodeToString(compressed)
}
return base64.RawURLEncoding.EncodeToString(jsonBytes)
}
// int64ToBytes encodes a non-negative int as minimal big-endian bytes, matching
// itsdangerous int_to_bytes (verifySessionCookie reads it back the same way).
func int64ToBytes(n int64) []byte {
if n == 0 {
return []byte{0}
}
var b []byte
for n > 0 {
b = append([]byte{byte(n & 0xff)}, b...)
n >>= 8
}
return b
}
func decodeItsdangerousPayload(p string) ([]byte, error) {
compressed := strings.HasPrefix(p, ".")
if compressed {
@ -130,7 +198,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
}
if c, err := r.Cookie("session"); err == nil {
if u := verifySessionCookie(s.secretKey, c.Value); u != nil {
next.ServeHTTP(w, r)
next.ServeHTTP(w, r.WithContext(withUser(r.Context(), u)))
return
}
}

View file

@ -39,6 +39,9 @@ type Ingestor struct {
vitalPeerState map[string]map[string]any
plugins *pluginRegistry // for share_* fan-out + plugin_connected status
invFwd *invForwarder // inventory forwarding (cutover only; nil in shadow/read)
aclog *aclogPoster // death/idle Discord alerts (cutover only; nil otherwise)
}
func newIngestor(pool *pgxpool.Pool, log *slog.Logger, broadcast func(map[string]any), plugins *pluginRegistry) *Ingestor {
@ -71,6 +74,17 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
switch {
case t == "telemetry" || (t == "" && hasTelemetryShape(data)):
i.handleTelemetry(ctx, data)
// Python broadcasts telemetry as a TYPELESS snapshot (snap.dict()); the
// browser intentionally ignores typeless messages (useLiveData drops
// `if (!msg.type) return`) and takes player data from the 5s /live poll
// instead. Broadcasting it WITH a type makes the UI overwrite the
// /live-derived telemetry (which has total_kills/total_rares/session_rares)
// with the raw plugin payload (which lacks them), flapping those counters
// 0<->value. Strip the type to match.
if i.broadcast != nil {
i.broadcast(stripType(data))
}
return
case t == "rare":
i.handleRare(ctx, data)
case t == "portal":
@ -91,6 +105,18 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
i.handleDungeonMap(data)
case t == "combat_stats":
i.handleCombatStats(ctx, data)
case t == "full_inventory":
// Forward the full snapshot to the inventory service; not browser-broadcast.
if i.invFwd != nil {
i.invFwd.forwardFullInventory(data)
}
return
case t == "inventory_delta":
// Fire-and-forget forward; the forwarder broadcasts the enriched delta.
if i.invFwd != nil {
i.invFwd.handleInventoryDelta(data)
}
return
case t == "share_subscribe":
i.handleShareSubscribe(data)
case t == "share_unsubscribe":
@ -108,6 +134,18 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
}
}
// stripType returns a shallow copy of the message without its "type" key, so the
// browser treats it as a typeless snapshot (and ignores it, deferring to /live).
func stripType(data map[string]any) map[string]any {
cp := make(map[string]any, len(data))
for k, v := range data {
if k != "type" {
cp[k] = v
}
}
return cp
}
func hasTelemetryShape(d map[string]any) bool {
_, a := d["session_id"]
_, b := d["ew"]
@ -312,8 +350,21 @@ func (i *Ingestor) handleVitals(data map[string]any) {
if name == "" {
return
}
// Death detection (discord alert) is intentionally omitted in shadow mode —
// it would duplicate the production alert. The live overlay still updates.
// Death detection (main.py:3419): vitae crossing 0 -> >0. Only in cutover
// (i.aclog != nil); in shadow mode it stays off to avoid duplicating the
// production alert.
if i.aclog != nil {
i.mu.RLock()
prev := i.liveVitals[name]
i.mu.RUnlock()
var prevVitae float64
if prev != nil {
prevVitae = toFloat(prev["vitae"])
}
if newVitae := toFloat(data["vitae"]); prevVitae == 0 && newVitae > 0 {
i.aclog.maybeDeath(name, newVitae)
}
}
i.mu.Lock()
i.liveVitals[name] = data
i.mu.Unlock()
@ -428,25 +479,22 @@ func nstr(v any) any {
}
return nil
}
// nint/nfloat return a typed number or nil (for nullable columns), coercing
// string-encoded numbers the plugin sends (see coerceNum).
func nint(v any) any {
switch x := v.(type) {
case float64:
return int64(x)
case int:
return int64(x)
case int64:
return x
if f, ok := coerceNum(v); ok {
return int64(f)
}
return nil
}
func nfloat(v any) any {
if f, ok := v.(float64); ok {
if f, ok := coerceNum(v); ok {
return f
}
return nil
}
func toFloatOr(v any, def float64) float64 {
if f, ok := v.(float64); ok {
if f, ok := coerceNum(v); ok {
return f
}
return def

View file

@ -0,0 +1,144 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// invForwarder forwards plugin inventory events to the inventory service,
// porting main.py's _forward_to_inventory_service / _handle_inventory_delta.
// Only active in cutover (write) mode; nil in shadow/read-only mode, where the
// plugin firehose never carries inventory anyway.
//
// full_inventory -> POST {url}/process-inventory (full replace)
// inventory_delta add/update -> POST {url}/inventory/{char}/item
// inventory_delta remove -> DELETE {url}/inventory/{char}/item/{item_id}
//
// Deltas are fire-and-forget (never block the /ws/position read loop), serialized
// per-character (so a char's rapid deltas don't race the inventory DELETE+INSERT),
// and globally capped at 8 concurrent forwards.
type invForwarder struct {
url string
client *http.Client
sem chan struct{}
mu sync.Mutex
locks map[string]*sync.Mutex
log *slog.Logger
broadcast func(map[string]any)
}
func newInvForwarder(rawURL string, log *slog.Logger, broadcast func(map[string]any)) *invForwarder {
return &invForwarder{
url: strings.TrimRight(rawURL, "/"),
client: &http.Client{Timeout: 30 * time.Second},
sem: make(chan struct{}, 8),
locks: map[string]*sync.Mutex{},
log: log,
broadcast: broadcast,
}
}
func (f *invForwarder) charLock(name string) *sync.Mutex {
f.mu.Lock()
defer f.mu.Unlock()
l := f.locks[name]
if l == nil {
l = &sync.Mutex{}
f.locks[name] = l
}
return l
}
// forwardFullInventory POSTs a full inventory snapshot (full replace). Runs
// inline on the /ws/position handler — main.py awaits _store_inventory too.
func (f *invForwarder) forwardFullInventory(data map[string]any) {
char := toStr(data["character_name"])
body, _ := json.Marshal(map[string]any{
"character_name": char,
"timestamp": data["timestamp"],
"items": data["items"],
})
resp, err := f.client.Post(f.url+"/process-inventory", "application/json", bytes.NewReader(body))
if err != nil {
f.log.Error("full_inventory forward failed", "err", err, "char", char)
return
}
defer drain(resp)
if resp.StatusCode >= 400 {
f.log.Warn("inventory service error (full_inventory)", "status", resp.StatusCode, "char", char)
}
}
// handleInventoryDelta forwards a single add/update/remove. Fire-and-forget.
func (f *invForwarder) handleInventoryDelta(data map[string]any) {
go func() {
char := toStr(data["character_name"])
lock := f.charLock(char)
lock.Lock()
defer lock.Unlock()
f.sem <- struct{}{}
defer func() { <-f.sem }()
out := data
switch toStr(data["action"]) {
case "remove":
if itemID := data["item_id"]; itemID != nil {
req, _ := http.NewRequest(http.MethodDelete,
fmt.Sprintf("%s/inventory/%s/item/%v", f.url, url.PathEscape(char), itemID), nil)
if resp, err := f.client.Do(req); err != nil {
f.log.Warn("inventory delta remove failed", "err", err, "char", char)
} else {
if resp.StatusCode >= 400 {
f.log.Warn("inventory service error (delta remove)", "status", resp.StatusCode, "char", char)
}
drain(resp)
}
}
case "add", "update":
if item := data["item"]; item != nil {
b, _ := json.Marshal(item)
resp, err := f.client.Post(fmt.Sprintf("%s/inventory/%s/item", f.url, url.PathEscape(char)),
"application/json", bytes.NewReader(b))
if err != nil {
f.log.Warn("inventory delta add/update failed", "err", err, "char", char)
} else {
if resp.StatusCode < 400 {
// Re-broadcast the enriched item the service returns.
var r map[string]any
if json.NewDecoder(resp.Body).Decode(&r) == nil {
if enriched, ok := r["item"]; ok && enriched != nil {
out = map[string]any{
"type": "inventory_delta",
"action": toStr(data["action"]),
"character_name": char,
"item": enriched,
}
}
}
} else {
f.log.Warn("inventory service error (delta add/update)", "status", resp.StatusCode, "char", char)
}
drain(resp)
}
}
}
if f.broadcast != nil {
f.broadcast(out)
}
}()
}
func drain(resp *http.Response) {
if resp != nil && resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}

View file

@ -44,6 +44,7 @@ type Server struct {
ingestor *Ingestor // non-nil only in ingest/shadow mode
hub *Hub // browser /ws/live fan-out
plugins *pluginRegistry
loginLimiter *loginLimiter
log *slog.Logger
}
@ -55,6 +56,13 @@ func main() {
runCombatMergeCLI()
return
}
// `tracker-go issue-cookie <username> <is_admin> <secret_key>` prints a
// session token — a hook to cross-check itsdangerous cookie interop with the
// Python service.
if len(os.Args) > 1 && os.Args[1] == "issue-cookie" {
runIssueCookieCLI()
return
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
@ -68,6 +76,7 @@ func main() {
srv := &Server{
cache: newLiveCache(),
totals: newTotalsCache(),
loginLimiter: newLoginLimiter(),
staticDir: cfg.StaticDir,
secretKey: cfg.SecretKey,
sharedSecret: cfg.SharedSecret,
@ -103,22 +112,38 @@ func main() {
defer pool.Close()
srv.pool = pool
// Ingest/shadow mode owns its own DB: create the schema on first run.
// Write mode (shadow OR cutover) owns the ingest path; read-only mode
// (parallel read API) skips all of this.
if !cfg.ReadOnly {
schemaCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
initSchema(schemaCtx, pool, logger)
cancel()
}
// Shadow ingest: replay the Python /ws/live firehose into our handlers.
if cfg.IngestWS != "" {
if cfg.ReadOnly {
logger.Error("SHADOW_INGEST_WS set but READ_ONLY=true; refusing to ingest into the production DB")
os.Exit(1)
// Schema init only when we own a fresh DB. In cutover (reusing the
// production DB) SKIP_SCHEMA_INIT keeps us from running ANY DDL.
if !cfg.SkipSchemaInit {
schemaCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
initSchema(schemaCtx, pool, logger)
cancel()
}
srv.ingestor = newIngestor(pool, logger, srv.hub.broadcast, srv.plugins)
go srv.runShadowConsumer(ctx, cfg.IngestWS)
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
if cfg.IngestWS != "" {
// Shadow: replay the Python /ws/live firehose. Inventory forwarding
// + Discord alerts stay OFF (would double production writes/alerts;
// inventory isn't in the firehose anyway).
go srv.runShadowConsumer(ctx, cfg.IngestWS)
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
} else {
// Cutover: the real plugin connects to /ws/position. Forward
// inventory to the inventory service and post death/idle alerts.
srv.ingestor.invFwd = newInvForwarder(cfg.InventoryURL, logger, srv.hub.broadcast)
if cfg.DiscordACLog != "" {
srv.ingestor.aclog = newACLogPoster(cfg.DiscordACLog, logger)
go srv.ingestor.aclog.runIdleLoop(ctx, pool)
}
logger.Info("cutover ingest enabled", "inventory_url", cfg.InventoryURL, "aclog", cfg.DiscordACLog != "")
}
} else if cfg.IngestWS != "" {
logger.Error("SHADOW_INGEST_WS set but READ_ONLY=true; refusing to ingest into the production DB")
os.Exit(1)
}
go srv.runCacheLoop(ctx)
@ -166,6 +191,8 @@ type config struct {
SharedSecret string // plugin /ws/position auth
SharedSecretLegacy string // plugin auth rotation fallback
IngestWS string // optional: a /ws/live URL to shadow-ingest from (Python tracker)
SkipSchemaInit bool // cutover: trust the existing prod schema, run no DDL
DiscordACLog string // #aclog webhook for death/idle alerts (cutover only)
}
func loadConfig() config {
@ -179,6 +206,8 @@ func loadConfig() config {
SharedSecret: os.Getenv("SHARED_SECRET"),
SharedSecretLegacy: os.Getenv("SHARED_SECRET_LEGACY"),
IngestWS: os.Getenv("SHADOW_INGEST_WS"),
SkipSchemaInit: envOr("SKIP_SCHEMA_INIT", "false") == "true",
DiscordACLog: os.Getenv("DISCORD_ACLOG_WEBHOOK"),
}
}
@ -230,6 +259,28 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
// Inventory-service reverse proxies.
s.registerProxyRoutes(mux)
// Website layer: login/logout + icons + static frontend (cutover).
mux.HandleFunc("GET /login", s.handleLoginGet)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("GET /logout", s.handleLogout)
mux.HandleFunc("GET /icons/{filename}", s.handleIcon)
// Admin user management.
mux.HandleFunc("GET /admin/users", s.handleAdminPage)
mux.HandleFunc("GET /api-admin/users", s.handleListUsers)
mux.HandleFunc("POST /api-admin/users", s.handleCreateUser)
mux.HandleFunc("PATCH /api-admin/users/{user_id}", s.handleUpdateUser)
mux.HandleFunc("DELETE /api-admin/users/{user_id}", s.handleDeleteUser)
// Issue board write side (GET /issues is registered above).
mux.HandleFunc("POST /issues", s.handleAddIssue)
mux.HandleFunc("PATCH /issues/{issue_id}", s.handleUpdateIssue)
mux.HandleFunc("POST /issues/{issue_id}/comments", s.handleAddComment)
mux.HandleFunc("DELETE /issues/{issue_id}", s.handleDeleteIssue)
// Catch-all: serve the static frontend (SPA). Registered last; every
// specific route above is more specific, so this only handles the rest.
mux.HandleFunc("GET /", s.handleStatic)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {

View file

@ -87,9 +87,13 @@ func (s *Server) loadIssues() []any {
return v
}
// GET /me — current user. Phase 1 has no session-cookie verification yet, so
// (like the Python service for an unauthenticated request) this is 401. The
// loopback internal-trust path carries no user identity. (main.py:1455)
// GET /me — current user from the session (main.py:1455). Internal-trust
// loopback requests carry no user identity, so they get 401 too.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Not authenticated"})
u := currentUser(r)
if u == nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Not authenticated"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"username": u.Username, "is_admin": u.IsAdmin})
}

View file

@ -5,9 +5,38 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// coerceNum converts a JSON value to a float64, parsing string-encoded numbers.
// The plugin sends several telemetry fields as strings (kills_per_hour, deaths,
// total_deaths, prismatic_taper_count via .ToString()); Python's pydantic
// coerced them, so Go must too or it writes null/0 (causing the live counters
// to flap 0<->value between the WS broadcast and the DB-derived /live poll).
func coerceNum(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int32:
return float64(x), true
case int64:
return float64(x), true
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
}
return 0, false
}
// reqCtx returns a child of the request context with a query timeout.
func reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), 15*time.Second)
@ -323,45 +352,16 @@ func join(parts []string, sep string) string {
}
func toFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int64:
return float64(x)
case int32:
return float64(x)
case int:
return float64(x)
}
return 0
f, _ := coerceNum(v)
return f
}
func toInt(v any) int {
switch x := v.(type) {
case int64:
return int(x)
case int32:
return int(x)
case int:
return x
case float64:
return int(x)
}
return 0
f, _ := coerceNum(v)
return int(f)
}
func toInt64(v any) int64 {
switch x := v.(type) {
case int64:
return x
case int32:
return int64(x)
case int:
return int64(x)
case float64:
return int64(x)
}
return 0
f, _ := coerceNum(v)
return int64(f)
}

View file

@ -0,0 +1,164 @@
package main
import (
"context"
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// Website-serving layer: static frontend + login/logout, porting main.py so the
// unchanged frontend loads on the Go tracker. Cookie issuing/verifying is in
// auth.go; this file is the handlers + the static file server.
// A fixed bcrypt hash used to keep the no-such-user path constant-time, matching
// Python's _DUMMY_HASH. (Hash of an arbitrary constant; never matches input.)
var dummyBcryptHash = []byte("$2a$12$C6UzMDM.H6dfI/f/IKcEeO3Jj6Q1jK7Z1qkq9b2yY6m4eW7N0pZ2K")
type loginLimiter struct {
mu sync.Mutex
last map[string]time.Time
}
func newLoginLimiter() *loginLimiter { return &loginLimiter{last: map[string]time.Time{}} }
// allow returns false if this IP attempted within the 5s cooldown (main.py).
func (l *loginLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
if t, ok := l.last[ip]; ok && now.Sub(t) < 5*time.Second {
return false
}
l.last[ip] = now
return true
}
// GET /login — serve the login page (main.py:login_page).
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
s.serveStaticFile(w, r, "login.html")
}
// POST /login — authenticate and set the session cookie (main.py:login).
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
ip = strings.TrimSpace(strings.Split(xff, ",")[0])
}
if !s.loginLimiter.allow(ip) {
writeJSON(w, http.StatusTooManyRequests, map[string]any{"detail": "Too many login attempts. Try again in a few seconds."})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Invalid request body"})
return
}
username := strings.ToLower(strings.TrimSpace(body.Username))
if username == "" || body.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var dbUser, hash string
var isAdmin bool
err := s.pool.QueryRow(ctx,
"SELECT username, password_hash, is_admin FROM users WHERE LOWER(username) = $1", username,
).Scan(&dbUser, &hash, &isAdmin)
// Constant-time: always run bcrypt, even when the user doesn't exist.
pwOK := false
if err == nil {
pwOK = bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) == nil
} else {
_ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(body.Password))
}
if !pwOK {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid username or password"})
return
}
token := issueSessionCookie(s.secretKey, sessionUser{Username: dbUser, IsAdmin: isAdmin})
http.SetCookie(w, &http.Cookie{
Name: "session", Value: token, Path: "/", MaxAge: sessionMaxAge,
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": dbUser, "is_admin": isAdmin})
}
// GET /logout — clear the cookie and redirect to /login (main.py:logout).
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusFound)
}
// GET /icons/{filename} — serve an icon file (main.py:serve_icon).
func (s *Server) handleIcon(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("filename")
if name == "" || strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
http.NotFound(w, r)
return
}
s.serveStaticFile(w, r, filepath.Join("icons", name))
}
// handleStatic is the catch-all GET handler: serves files from staticDir, falls
// back to index.html for SPA routes (React client-side routing). Registered last
// so the specific API routes take precedence.
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
upath := path.Clean("/" + r.URL.Path)
full := filepath.Join(s.staticDir, filepath.FromSlash(upath))
// Guard against path traversal escaping staticDir.
if rel, err := filepath.Rel(s.staticDir, full); err != nil || strings.HasPrefix(rel, "..") {
http.NotFound(w, r)
return
}
if info, err := os.Stat(full); err == nil {
if info.IsDir() {
if idx := filepath.Join(full, "index.html"); fileExists(idx) {
http.ServeFile(w, r, idx)
return
}
} else {
http.ServeFile(w, r, full)
return
}
}
// SPA fallback — serve the app shell for unknown (client-routed) paths.
http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html"))
}
func (s *Server) serveStaticFile(w http.ResponseWriter, r *http.Request, rel string) {
full := filepath.Join(s.staticDir, filepath.FromSlash(rel))
if !fileExists(full) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, full)
}
func fileExists(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
}
// runIssueCookieCLI prints a session token for cross-checking itsdangerous
// cookie interop with the Python service.
func runIssueCookieCLI() {
if len(os.Args) < 5 {
os.Stderr.WriteString("usage: tracker-go issue-cookie <username> <is_admin:true|false> <secret_key>\n")
os.Exit(2)
}
os.Stdout.WriteString(issueSessionCookie(os.Args[4], sessionUser{Username: os.Args[2], IsAdmin: os.Args[3] == "true"}))
}

View file

@ -0,0 +1,151 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
// Admin user management — port of main.py's /admin + /api-admin/users routes.
// All require an admin session (requireAdmin). Writes only succeed in write
// (cutover) mode; on the read-only parallel instance the txn is rejected.
// GET /admin/users — serve the admin page (admin only).
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
s.serveStaticFile(w, r, "admin.html")
}
// GET /api-admin/users — list users (admin only).
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
rows, err := s.pool.Query(ctx, "SELECT id, username, is_admin, created_at FROM users ORDER BY id")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "db error"})
return
}
defer rows.Close()
users := []map[string]any{}
for rows.Next() {
var id int
var username string
var isAdmin bool
var createdAt time.Time
if rows.Scan(&id, &username, &isAdmin, &createdAt) != nil {
continue
}
users = append(users, map[string]any{
"id": id, "username": username, "is_admin": isAdmin,
"created_at": createdAt.UTC().Format("2006-01-02T15:04:05.999999"),
})
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
// POST /api-admin/users — create a user (admin only).
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
}
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
username := strings.TrimSpace(body.Username)
if username == "" || body.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"})
return
}
if len(body.Password) < 4 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Password must be at least 4 characters"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var existing int
if s.pool.QueryRow(ctx, "SELECT id FROM users WHERE LOWER(username) = $1", strings.ToLower(username)).Scan(&existing) == nil {
writeJSON(w, http.StatusConflict, map[string]any{"detail": "Username already exists"})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(body.Password), 12)
if _, err := s.pool.Exec(ctx, "INSERT INTO users (username, password_hash, is_admin) VALUES ($1,$2,$3)", username, string(hash), body.IsAdmin); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Failed to create user"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": username})
}
// PATCH /api-admin/users/{user_id} — password reset / admin toggle (admin only).
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
id, _ := strconv.Atoi(r.PathValue("user_id"))
var body map[string]any
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var exists int
if errors.Is(s.pool.QueryRow(ctx, "SELECT id FROM users WHERE id = $1", id).Scan(&exists), pgx.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "User not found"})
return
}
if pw, ok := body["password"].(string); ok {
if len(pw) < 4 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Password must be at least 4 characters"})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(pw), 12)
if _, err := s.pool.Exec(ctx, "UPDATE users SET password_hash = $1 WHERE id = $2", string(hash), id); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "update failed"})
return
}
}
if a, ok := body["is_admin"]; ok {
isAdmin, _ := a.(bool)
if _, err := s.pool.Exec(ctx, "UPDATE users SET is_admin = $1 WHERE id = $2", isAdmin, id); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "update failed"})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// DELETE /api-admin/users/{user_id} — delete a user (admin only, not yourself).
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
id, _ := strconv.Atoi(r.PathValue("user_id"))
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var username string
if errors.Is(s.pool.QueryRow(ctx, "SELECT username FROM users WHERE id = $1", id).Scan(&username), pgx.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "User not found"})
return
}
if cur := currentUser(r); cur != nil && strings.EqualFold(username, cur.Username) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Cannot delete yourself"})
return
}
if _, err := s.pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "delete failed"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

View file

@ -0,0 +1,192 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Issue board write side — port of main.py's POST/PATCH/DELETE /issues. Issues
// live in static/openissues.json (the same flat file the read side uses); writes
// are serialized by issuesMu. Needs the file mounted read-write in cutover.
var issuesMu sync.Mutex
func (s *Server) issuesPath() string { return filepath.Join(s.staticDir, "openissues.json") }
func (s *Server) loadIssuesRW() []map[string]any {
b, err := os.ReadFile(s.issuesPath())
if err != nil {
return []map[string]any{}
}
var v []map[string]any
if json.Unmarshal(b, &v) != nil {
return []map[string]any{}
}
return v
}
func (s *Server) saveIssues(issues []map[string]any) error {
b, _ := json.MarshalIndent(issues, "", " ")
return os.WriteFile(s.issuesPath(), b, 0o644)
}
func issueAuthor(r *http.Request) string {
if u := currentUser(r); u != nil {
return u.Username
}
return "Anonymous"
}
func nowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.999999") }
func randHex8() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// pyHTMLEscape matches Python's html.escape(s, quote=True).
func pyHTMLEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#x27;")
return s
}
// POST /issues
func (s *Server) handleAddIssue(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
title := pyHTMLEscape(strings.TrimSpace(toStr(body["title"])))
if title == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Title is required"})
return
}
category := strings.TrimSpace(toStr(body["category"]))
if category == "" {
category = "other"
}
newIssue := map[string]any{
"id": randHex8(),
"title": title,
"description": pyHTMLEscape(strings.TrimSpace(toStr(body["description"]))),
"category": pyHTMLEscape(category),
"author": issueAuthor(r),
"created": nowISO(),
"resolved": false,
"comments": []any{},
}
issuesMu.Lock()
defer issuesMu.Unlock()
issues := append([]map[string]any{newIssue}, s.loadIssuesRW()...)
if err := s.saveIssues(issues); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"})
return
}
writeJSON(w, http.StatusOK, newIssue)
}
// PATCH /issues/{issue_id}
func (s *Server) handleUpdateIssue(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("issue_id")
var update map[string]any
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&update)
issuesMu.Lock()
defer issuesMu.Unlock()
issues := s.loadIssuesRW()
var found map[string]any
for _, i := range issues {
if toStr(i["id"]) == id {
if v, ok := update["resolved"]; ok {
b, _ := v.(bool)
i["resolved"] = b
}
if v, ok := update["title"]; ok {
t := pyHTMLEscape(strings.TrimSpace(toStr(v)))
if t == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Title cannot be empty"})
return
}
i["title"] = t
}
if v, ok := update["description"]; ok {
i["description"] = pyHTMLEscape(strings.TrimSpace(toStr(v)))
}
if v, ok := update["category"]; ok {
i["category"] = pyHTMLEscape(toStr(v))
}
found = i
break
}
}
if found == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Issue not found"})
return
}
if err := s.saveIssues(issues); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"})
return
}
writeJSON(w, http.StatusOK, found)
}
// POST /issues/{issue_id}/comments
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("issue_id")
var body map[string]any
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
issuesMu.Lock()
defer issuesMu.Unlock()
issues := s.loadIssuesRW()
var found map[string]any
for _, i := range issues {
if toStr(i["id"]) == id {
found = i
break
}
}
if found == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Issue not found"})
return
}
text := pyHTMLEscape(strings.TrimSpace(toStr(body["text"])))
if text == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Comment text is required"})
return
}
comment := map[string]any{"id": randHex8(), "author": issueAuthor(r), "text": text, "created": nowISO()}
comments, _ := found["comments"].([]any)
found["comments"] = append(comments, comment)
if err := s.saveIssues(issues); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"})
return
}
writeJSON(w, http.StatusOK, comment)
}
// DELETE /issues/{issue_id}
func (s *Server) handleDeleteIssue(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("issue_id")
issuesMu.Lock()
defer issuesMu.Unlock()
kept := []map[string]any{}
for _, i := range s.loadIssuesRW() {
if toStr(i["id"]) != id {
kept = append(kept, i)
}
}
if err := s.saveIssues(kept); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}