From 5ade47dc64dac191c56ab85b7444ce8690c8293c Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 19:46:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Go=20backend=20production=20cutover=20?= =?UTF-8?q?=E2=80=94=20website=20layer,=20ingest=20forwarding,=20alerts,?= =?UTF-8?q?=20live=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go-services/docker-compose.cutover.yml | 32 ++++ go-services/docker-compose.go.yml | 8 + go-services/inventory-go/main.go | 5 +- go-services/tracker-go/aclog.go | 145 +++++++++++++++ go-services/tracker-go/auth.go | 70 ++++++- go-services/tracker-go/ingest.go | 70 +++++-- go-services/tracker-go/inventory_forward.go | 144 +++++++++++++++ go-services/tracker-go/main.go | 77 ++++++-- go-services/tracker-go/memstate.go | 12 +- go-services/tracker-go/reads.go | 70 +++---- go-services/tracker-go/website.go | 164 +++++++++++++++++ go-services/tracker-go/website_admin.go | 151 +++++++++++++++ go-services/tracker-go/website_issues.go | 192 ++++++++++++++++++++ 13 files changed, 1074 insertions(+), 66 deletions(-) create mode 100644 go-services/docker-compose.cutover.yml create mode 100644 go-services/tracker-go/aclog.go create mode 100644 go-services/tracker-go/inventory_forward.go create mode 100644 go-services/tracker-go/website.go create mode 100644 go-services/tracker-go/website_admin.go create mode 100644 go-services/tracker-go/website_issues.go diff --git a/go-services/docker-compose.cutover.yml b/go-services/docker-compose.cutover.yml new file mode 100644 index 00000000..1f813918 --- /dev/null +++ b/go-services/docker-compose.cutover.yml @@ -0,0 +1,32 @@ +# Cutover override — flips the Go services from read-only parallel mode to +# PRODUCTION write mode, reusing the existing production databases (no data +# migration). Apply ON TOP of the base + go overrides: +# +# docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \ +# -f go-services/docker-compose.cutover.yml up -d --no-deps \ +# dereth-tracker-go inventory-go discord-rare-monitor +# +# Reversible: re-up WITHOUT this file to return the Go services to read-only +# parallel mode (and start the Python services back up for rollback). +# +# SKIP_SCHEMA_INIT=true makes the Go services trust the existing prod schema and +# run NO DDL. The Go tracker writes prod `dereth`; inventory-go writes prod +# `inventory_db`; the (still Python) rare/chat bot is repointed at the Go +# tracker's /ws/live (proven posting path, fed by Go data). +services: + dereth-tracker-go: + environment: + READ_ONLY: "false" + SKIP_SCHEMA_INIT: "true" + SHARED_SECRET: "${SHARED_SECRET}" + SHARED_SECRET_LEGACY: "${SHARED_SECRET_LEGACY:-}" + DISCORD_ACLOG_WEBHOOK: "${DISCORD_ACLOG_WEBHOOK}" + + inventory-go: + environment: + READ_ONLY: "false" + SKIP_SCHEMA_INIT: "true" + + discord-rare-monitor: + environment: + DERETH_TRACKER_WS_URL: "ws://dereth-tracker-go:8770/ws/live" diff --git a/go-services/docker-compose.go.yml b/go-services/docker-compose.go.yml index a0667609..2e64c737 100644 --- a/go-services/docker-compose.go.yml +++ b/go-services/docker-compose.go.yml @@ -37,7 +37,15 @@ services: # Same signing key as the Python tracker so the same login cookie verifies # on both during the parallel run. SECRET_KEY: "${SECRET_KEY}" + # Serve the (unchanged) frontend from the same static/ the Python tracker + # serves — needed for the full cutover (login, index.html, assets, icons). + STATIC_DIR: "/static" LOG_LEVEL: "INFO" + volumes: + - ./static:/static:ro + # Issue board is a flat file the tracker writes; mount it read-write + # (more specific than the :ro static mount above, so it wins). + - ./static/openissues.json:/static/openissues.json depends_on: - db restart: unless-stopped diff --git a/go-services/inventory-go/main.go b/go-services/inventory-go/main.go index 4ac34f7c..ffe56787 100644 --- a/go-services/inventory-go/main.go +++ b/go-services/inventory-go/main.go @@ -80,8 +80,9 @@ func main() { defer pool.Close() srv.pool = pool - // Ingest mode owns its DB: create the schema on first run. - if !readOnly { + // Ingest mode owns its DB: create the schema on first run. In cutover + // (reusing the production inventory_db) SKIP_SCHEMA_INIT runs no DDL. + if !readOnly && envOr("SKIP_SCHEMA_INIT", "false") != "true" { sctx, c := context.WithTimeout(ctx, 60*time.Second) initSchema(sctx, pool, logger) c() diff --git a/go-services/tracker-go/aclog.go b/go-services/tracker-go/aclog.go new file mode 100644 index 00000000..608d86a7 --- /dev/null +++ b/go-services/tracker-go/aclog.go @@ -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) + } + } +} diff --git a/go-services/tracker-go/auth.go b/go-services/tracker-go/auth.go index 439ba27c..78c483ad 100644 --- a/go-services/tracker-go/auth.go +++ b/go-services/tracker-go/auth.go @@ -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 } } diff --git a/go-services/tracker-go/ingest.go b/go-services/tracker-go/ingest.go index 925aaf80..1b8c0ca2 100644 --- a/go-services/tracker-go/ingest.go +++ b/go-services/tracker-go/ingest.go @@ -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 diff --git a/go-services/tracker-go/inventory_forward.go b/go-services/tracker-go/inventory_forward.go new file mode 100644 index 00000000..a5c6e70b --- /dev/null +++ b/go-services/tracker-go/inventory_forward.go @@ -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() + } +} diff --git a/go-services/tracker-go/main.go b/go-services/tracker-go/main.go index 742459b1..b5cf5a44 100644 --- a/go-services/tracker-go/main.go +++ b/go-services/tracker-go/main.go @@ -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 ` 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) { diff --git a/go-services/tracker-go/memstate.go b/go-services/tracker-go/memstate.go index 8c0f8853..27ba80d8 100644 --- a/go-services/tracker-go/memstate.go +++ b/go-services/tracker-go/memstate.go @@ -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}) } diff --git a/go-services/tracker-go/reads.go b/go-services/tracker-go/reads.go index f477b23b..0de945f1 100644 --- a/go-services/tracker-go/reads.go +++ b/go-services/tracker-go/reads.go @@ -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) } diff --git a/go-services/tracker-go/website.go b/go-services/tracker-go/website.go new file mode 100644 index 00000000..f3f9e2cd --- /dev/null +++ b/go-services/tracker-go/website.go @@ -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 \n") + os.Exit(2) + } + os.Stdout.WriteString(issueSessionCookie(os.Args[4], sessionUser{Username: os.Args[2], IsAdmin: os.Args[3] == "true"})) +} diff --git a/go-services/tracker-go/website_admin.go b/go-services/tracker-go/website_admin.go new file mode 100644 index 00000000..bf2a1dd4 --- /dev/null +++ b/go-services/tracker-go/website_admin.go @@ -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}) +} diff --git a/go-services/tracker-go/website_issues.go b/go-services/tracker-go/website_issues.go new file mode 100644 index 00000000..810ddaec --- /dev/null +++ b/go-services/tracker-go/website_issues.go @@ -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, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + 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"}) +}