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
151
go-services/tracker-go/website_admin.go
Normal file
151
go-services/tracker-go/website_admin.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Admin user management — port of main.py's /admin + /api-admin/users routes.
|
||||
// All require an admin session (requireAdmin). Writes only succeed in write
|
||||
// (cutover) mode; on the read-only parallel instance the txn is rejected.
|
||||
|
||||
// GET /admin/users — serve the admin page (admin only).
|
||||
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
s.serveStaticFile(w, r, "admin.html")
|
||||
}
|
||||
|
||||
// GET /api-admin/users — list users (admin only).
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
rows, err := s.pool.Query(ctx, "SELECT id, username, is_admin, created_at FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "db error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username string
|
||||
var isAdmin bool
|
||||
var createdAt time.Time
|
||||
if rows.Scan(&id, &username, &isAdmin, &createdAt) != nil {
|
||||
continue
|
||||
}
|
||||
users = append(users, map[string]any{
|
||||
"id": id, "username": username, "is_admin": isAdmin,
|
||||
"created_at": createdAt.UTC().Format("2006-01-02T15:04:05.999999"),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
// POST /api-admin/users — create a user (admin only).
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
|
||||
username := strings.TrimSpace(body.Username)
|
||||
if username == "" || body.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"})
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 4 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Password must be at least 4 characters"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var existing int
|
||||
if s.pool.QueryRow(ctx, "SELECT id FROM users WHERE LOWER(username) = $1", strings.ToLower(username)).Scan(&existing) == nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"detail": "Username already exists"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(body.Password), 12)
|
||||
if _, err := s.pool.Exec(ctx, "INSERT INTO users (username, password_hash, is_admin) VALUES ($1,$2,$3)", username, string(hash), body.IsAdmin); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": username})
|
||||
}
|
||||
|
||||
// PATCH /api-admin/users/{user_id} — password reset / admin toggle (admin only).
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(r.PathValue("user_id"))
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var exists int
|
||||
if errors.Is(s.pool.QueryRow(ctx, "SELECT id FROM users WHERE id = $1", id).Scan(&exists), pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "User not found"})
|
||||
return
|
||||
}
|
||||
if pw, ok := body["password"].(string); ok {
|
||||
if len(pw) < 4 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Password must be at least 4 characters"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(pw), 12)
|
||||
if _, err := s.pool.Exec(ctx, "UPDATE users SET password_hash = $1 WHERE id = $2", string(hash), id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "update failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if a, ok := body["is_admin"]; ok {
|
||||
isAdmin, _ := a.(bool)
|
||||
if _, err := s.pool.Exec(ctx, "UPDATE users SET is_admin = $1 WHERE id = $2", isAdmin, id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "update failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// DELETE /api-admin/users/{user_id} — delete a user (admin only, not yourself).
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(r.PathValue("user_id"))
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var username string
|
||||
if errors.Is(s.pool.QueryRow(ctx, "SELECT username FROM users WHERE id = $1", id).Scan(&username), pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "User not found"})
|
||||
return
|
||||
}
|
||||
if cur := currentUser(r); cur != nil && strings.EqualFold(username, cur.Username) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Cannot delete yourself"})
|
||||
return
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "delete failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue