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>
99 lines
2.8 KiB
Go
99 lines
2.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
)
|
|
|
|
// These endpoints are backed by ingest-only in-memory state in the Python
|
|
// service (populated from /ws/position events). Phase 1 has no ingest, so they
|
|
// return the same empty/default shapes the Python service emits when no data is
|
|
// present — preserving the API contract for the frontend.
|
|
|
|
// GET /quest-status (main.py:1940)
|
|
func (s *Server) handleQuestStatus(w http.ResponseWriter, r *http.Request) {
|
|
questData := map[string]any{}
|
|
playerCount := 0
|
|
if s.ingestor != nil {
|
|
qd, n := s.ingestor.questData()
|
|
playerCount = n
|
|
for c, qs := range qd {
|
|
m := map[string]any{}
|
|
for k, v := range qs {
|
|
m[k] = v
|
|
}
|
|
questData[c] = m
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"quest_data": questData,
|
|
"tracked_quests": []string{
|
|
"Stipend Collection Timer",
|
|
"Blank Augmentation Gem Pickup Timer",
|
|
"Insatiable Eater Jaw",
|
|
},
|
|
"player_count": playerCount,
|
|
})
|
|
}
|
|
|
|
// GET /vital-sharing/peers (main.py:1800)
|
|
func (s *Server) handleVitalSharingPeers(w http.ResponseWriter, r *http.Request) {
|
|
if s.ingestor == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"peers": []any{}, "subscriber_count": 0})
|
|
return
|
|
}
|
|
peers, subCount := s.ingestor.vitalSharingPeers()
|
|
sort.Slice(peers, func(i, j int) bool {
|
|
return toStr(peers[i]["character_name"]) < toStr(peers[j]["character_name"])
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]any{"peers": peers, "subscriber_count": subCount})
|
|
}
|
|
|
|
// GET /equipment-cantrip-state/{name} (main.py:4167)
|
|
func (s *Server) handleEquipmentCantrip(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if s.ingestor != nil {
|
|
if v, ok := s.ingestor.getEquipmentCantrip(name); ok {
|
|
writeJSON(w, http.StatusOK, v)
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"type": "equipment_cantrip_state",
|
|
"character_name": name,
|
|
"items": []any{},
|
|
})
|
|
}
|
|
|
|
// GET /issues — flat-file issue board. (main.py:1709)
|
|
func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
|
|
issues := s.loadIssues()
|
|
writeJSON(w, http.StatusOK, map[string]any{"issues": issues})
|
|
}
|
|
|
|
func (s *Server) loadIssues() []any {
|
|
empty := []any{}
|
|
b, err := os.ReadFile(filepath.Join(s.staticDir, "openissues.json"))
|
|
if err != nil {
|
|
return empty
|
|
}
|
|
var v []any
|
|
if json.Unmarshal(b, &v) != nil {
|
|
return empty
|
|
}
|
|
return v
|
|
}
|
|
|
|
// 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) {
|
|
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})
|
|
}
|