MosswartOverlord/go-services/tracker-go/website_issues.go
Erik 5ade47dc64 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>
2026-06-24 19:46:40 +02:00

192 lines
5.3 KiB
Go

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"})
}