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:
parent
776076b981
commit
5ade47dc64
13 changed files with 1074 additions and 66 deletions
192
go-services/tracker-go/website_issues.go
Normal file
192
go-services/tracker-go/website_issues.go
Normal 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, "&", "&")
|
||||
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"})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue