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:
Erik 2026-06-24 19:46:40 +02:00
parent 776076b981
commit 5ade47dc64
13 changed files with 1074 additions and 66 deletions

View file

@ -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
}
}