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
164
go-services/tracker-go/website.go
Normal file
164
go-services/tracker-go/website.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Website-serving layer: static frontend + login/logout, porting main.py so the
|
||||
// unchanged frontend loads on the Go tracker. Cookie issuing/verifying is in
|
||||
// auth.go; this file is the handlers + the static file server.
|
||||
|
||||
// A fixed bcrypt hash used to keep the no-such-user path constant-time, matching
|
||||
// Python's _DUMMY_HASH. (Hash of an arbitrary constant; never matches input.)
|
||||
var dummyBcryptHash = []byte("$2a$12$C6UzMDM.H6dfI/f/IKcEeO3Jj6Q1jK7Z1qkq9b2yY6m4eW7N0pZ2K")
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
last map[string]time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter { return &loginLimiter{last: map[string]time.Time{}} }
|
||||
|
||||
// allow returns false if this IP attempted within the 5s cooldown (main.py).
|
||||
func (l *loginLimiter) allow(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
if t, ok := l.last[ip]; ok && now.Sub(t) < 5*time.Second {
|
||||
return false
|
||||
}
|
||||
l.last[ip] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// GET /login — serve the login page (main.py:login_page).
|
||||
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveStaticFile(w, r, "login.html")
|
||||
}
|
||||
|
||||
// POST /login — authenticate and set the session cookie (main.py:login).
|
||||
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
ip = strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
}
|
||||
if !s.loginLimiter.allow(ip) {
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{"detail": "Too many login attempts. Try again in a few seconds."})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
username := strings.ToLower(strings.TrimSpace(body.Username))
|
||||
if username == "" || body.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var dbUser, hash string
|
||||
var isAdmin bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
"SELECT username, password_hash, is_admin FROM users WHERE LOWER(username) = $1", username,
|
||||
).Scan(&dbUser, &hash, &isAdmin)
|
||||
// Constant-time: always run bcrypt, even when the user doesn't exist.
|
||||
pwOK := false
|
||||
if err == nil {
|
||||
pwOK = bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) == nil
|
||||
} else {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(body.Password))
|
||||
}
|
||||
if !pwOK {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
token := issueSessionCookie(s.secretKey, sessionUser{Username: dbUser, IsAdmin: isAdmin})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session", Value: token, Path: "/", MaxAge: sessionMaxAge,
|
||||
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": dbUser, "is_admin": isAdmin})
|
||||
}
|
||||
|
||||
// GET /logout — clear the cookie and redirect to /login (main.py:logout).
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// GET /icons/{filename} — serve an icon file (main.py:serve_icon).
|
||||
func (s *Server) handleIcon(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("filename")
|
||||
if name == "" || strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.serveStaticFile(w, r, filepath.Join("icons", name))
|
||||
}
|
||||
|
||||
// handleStatic is the catch-all GET handler: serves files from staticDir, falls
|
||||
// back to index.html for SPA routes (React client-side routing). Registered last
|
||||
// so the specific API routes take precedence.
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
upath := path.Clean("/" + r.URL.Path)
|
||||
full := filepath.Join(s.staticDir, filepath.FromSlash(upath))
|
||||
// Guard against path traversal escaping staticDir.
|
||||
if rel, err := filepath.Rel(s.staticDir, full); err != nil || strings.HasPrefix(rel, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(full); err == nil {
|
||||
if info.IsDir() {
|
||||
if idx := filepath.Join(full, "index.html"); fileExists(idx) {
|
||||
http.ServeFile(w, r, idx)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.ServeFile(w, r, full)
|
||||
return
|
||||
}
|
||||
}
|
||||
// SPA fallback — serve the app shell for unknown (client-routed) paths.
|
||||
http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html"))
|
||||
}
|
||||
|
||||
func (s *Server) serveStaticFile(w http.ResponseWriter, r *http.Request, rel string) {
|
||||
full := filepath.Join(s.staticDir, filepath.FromSlash(rel))
|
||||
if !fileExists(full) {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, full)
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// runIssueCookieCLI prints a session token for cross-checking itsdangerous
|
||||
// cookie interop with the Python service.
|
||||
func runIssueCookieCLI() {
|
||||
if len(os.Args) < 5 {
|
||||
os.Stderr.WriteString("usage: tracker-go issue-cookie <username> <is_admin:true|false> <secret_key>\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Stdout.WriteString(issueSessionCookie(os.Args[4], sessionUser{Username: os.Args[2], IsAdmin: os.Args[3] == "true"}))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue