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>
192 lines
5.3 KiB
Go
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, "&", "&")
|
|
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"})
|
|
}
|