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
32
go-services/docker-compose.cutover.yml
Normal file
32
go-services/docker-compose.cutover.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Cutover override — flips the Go services from read-only parallel mode to
|
||||||
|
# PRODUCTION write mode, reusing the existing production databases (no data
|
||||||
|
# migration). Apply ON TOP of the base + go overrides:
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \
|
||||||
|
# -f go-services/docker-compose.cutover.yml up -d --no-deps \
|
||||||
|
# dereth-tracker-go inventory-go discord-rare-monitor
|
||||||
|
#
|
||||||
|
# Reversible: re-up WITHOUT this file to return the Go services to read-only
|
||||||
|
# parallel mode (and start the Python services back up for rollback).
|
||||||
|
#
|
||||||
|
# SKIP_SCHEMA_INIT=true makes the Go services trust the existing prod schema and
|
||||||
|
# run NO DDL. The Go tracker writes prod `dereth`; inventory-go writes prod
|
||||||
|
# `inventory_db`; the (still Python) rare/chat bot is repointed at the Go
|
||||||
|
# tracker's /ws/live (proven posting path, fed by Go data).
|
||||||
|
services:
|
||||||
|
dereth-tracker-go:
|
||||||
|
environment:
|
||||||
|
READ_ONLY: "false"
|
||||||
|
SKIP_SCHEMA_INIT: "true"
|
||||||
|
SHARED_SECRET: "${SHARED_SECRET}"
|
||||||
|
SHARED_SECRET_LEGACY: "${SHARED_SECRET_LEGACY:-}"
|
||||||
|
DISCORD_ACLOG_WEBHOOK: "${DISCORD_ACLOG_WEBHOOK}"
|
||||||
|
|
||||||
|
inventory-go:
|
||||||
|
environment:
|
||||||
|
READ_ONLY: "false"
|
||||||
|
SKIP_SCHEMA_INIT: "true"
|
||||||
|
|
||||||
|
discord-rare-monitor:
|
||||||
|
environment:
|
||||||
|
DERETH_TRACKER_WS_URL: "ws://dereth-tracker-go:8770/ws/live"
|
||||||
|
|
@ -37,7 +37,15 @@ services:
|
||||||
# Same signing key as the Python tracker so the same login cookie verifies
|
# Same signing key as the Python tracker so the same login cookie verifies
|
||||||
# on both during the parallel run.
|
# on both during the parallel run.
|
||||||
SECRET_KEY: "${SECRET_KEY}"
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
|
# Serve the (unchanged) frontend from the same static/ the Python tracker
|
||||||
|
# serves — needed for the full cutover (login, index.html, assets, icons).
|
||||||
|
STATIC_DIR: "/static"
|
||||||
LOG_LEVEL: "INFO"
|
LOG_LEVEL: "INFO"
|
||||||
|
volumes:
|
||||||
|
- ./static:/static:ro
|
||||||
|
# Issue board is a flat file the tracker writes; mount it read-write
|
||||||
|
# (more specific than the :ro static mount above, so it wins).
|
||||||
|
- ./static/openissues.json:/static/openissues.json
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,9 @@ func main() {
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
srv.pool = pool
|
srv.pool = pool
|
||||||
|
|
||||||
// Ingest mode owns its DB: create the schema on first run.
|
// Ingest mode owns its DB: create the schema on first run. In cutover
|
||||||
if !readOnly {
|
// (reusing the production inventory_db) SKIP_SCHEMA_INIT runs no DDL.
|
||||||
|
if !readOnly && envOr("SKIP_SCHEMA_INIT", "false") != "true" {
|
||||||
sctx, c := context.WithTimeout(ctx, 60*time.Second)
|
sctx, c := context.WithTimeout(ctx, 60*time.Second)
|
||||||
initSchema(sctx, pool, logger)
|
initSchema(sctx, pool, logger)
|
||||||
c()
|
c()
|
||||||
|
|
|
||||||
145
go-services/tracker-go/aclog.go
Normal file
145
go-services/tracker-go/aclog.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trimFloat formats a vitae value without a trailing ".0" for whole numbers.
|
||||||
|
func trimFloat(f float64) string {
|
||||||
|
if f == float64(int64(f)) {
|
||||||
|
return strconv.FormatInt(int64(f), 10)
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aclogPoster posts death + idle alerts to the #aclog Discord webhook, porting
|
||||||
|
// main.py's _send_discord_aclog / death detection / _idle_detection_loop. nil
|
||||||
|
// when DISCORD_ACLOG_WEBHOOK is unset (or in shadow mode).
|
||||||
|
type aclogPoster struct {
|
||||||
|
webhook string
|
||||||
|
client *http.Client
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
deathAlerted map[string]time.Time // char -> last death alert (max 1 / 5min)
|
||||||
|
idleSince map[string]time.Time // char -> first detected idle
|
||||||
|
idleAlerted map[string]bool // char -> already alerted this idle period
|
||||||
|
}
|
||||||
|
|
||||||
|
func newACLogPoster(webhook string, log *slog.Logger) *aclogPoster {
|
||||||
|
return &aclogPoster{
|
||||||
|
webhook: webhook,
|
||||||
|
client: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
log: log,
|
||||||
|
deathAlerted: map[string]time.Time{},
|
||||||
|
idleSince: map[string]time.Time{},
|
||||||
|
idleAlerted: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *aclogPoster) post(message string) {
|
||||||
|
if a == nil || a.webhook == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": message})
|
||||||
|
resp, err := a.client.Post(a.webhook, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
a.log.Debug("discord webhook failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drain(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeDeath fires a death alert when vitae crosses 0 -> >0, capped at 1 per
|
||||||
|
// 5 minutes per character (main.py:3419).
|
||||||
|
func (a *aclogPoster) maybeDeath(name string, vitae float64) {
|
||||||
|
if a == nil || a.webhook == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
last, ok := a.deathAlerted[name]
|
||||||
|
if ok && time.Since(last) <= 5*time.Minute {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.deathAlerted[name] = time.Now()
|
||||||
|
a.mu.Unlock()
|
||||||
|
go a.post(fmt.Sprintf("☠️ **%s** died! (vitae: %s%%)", name, trimFloat(vitae)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runIdleLoop polls online players every 60s and alerts on idle (main.py:2694).
|
||||||
|
func (a *aclogPoster) runIdleLoop(ctx context.Context, pool *pgxpool.Pool) {
|
||||||
|
if a == nil || a.webhook == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(30 * time.Second): // let telemetry arrive first
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := time.NewTicker(60 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
a.checkIdleOnce(ctx, pool)
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *aclogPoster) checkIdleOnce(ctx context.Context, pool *pgxpool.Pool) {
|
||||||
|
qctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
rows, err := pool.Query(qctx, `
|
||||||
|
SELECT DISTINCT ON (character_name) character_name, COALESCE(vt_state,''), COALESCE(kills_per_hour, 0)
|
||||||
|
FROM telemetry_events
|
||||||
|
WHERE COALESCE(received_at, timestamp) > now() - interval '30 seconds'
|
||||||
|
ORDER BY character_name, timestamp DESC`)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Debug("idle query failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
now := time.Now()
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
for rows.Next() {
|
||||||
|
var name, vtState string
|
||||||
|
var kph float64
|
||||||
|
if rows.Scan(&name, &vtState, &kph) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := strings.ToLower(vtState)
|
||||||
|
kphi := int(kph)
|
||||||
|
isIdle := s == "default" || s == "idle" || s == "" || ((s == "combat" || s == "hunt") && kphi == 0)
|
||||||
|
if isIdle {
|
||||||
|
if _, seen := a.idleSince[name]; !seen {
|
||||||
|
a.idleSince[name] = now
|
||||||
|
} else if !a.idleAlerted[name] && now.Sub(a.idleSince[name]) >= 5*time.Minute {
|
||||||
|
a.idleAlerted[name] = true
|
||||||
|
idleMins := int(now.Sub(a.idleSince[name]).Minutes())
|
||||||
|
stateText := vtState
|
||||||
|
if stateText == "" {
|
||||||
|
stateText = "idle"
|
||||||
|
}
|
||||||
|
go a.post(fmt.Sprintf("⚠️ **%s** appears idle for %dmin (state: %s, KPH: %d)", name, idleMins, stateText, kphi))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete(a.idleAlerted, name)
|
||||||
|
delete(a.idleSince, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/zlib"
|
"compress/zlib"
|
||||||
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
|
@ -14,6 +15,29 @@ import (
|
||||||
"time"
|
"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
|
// Session-cookie verification compatible with the Python service's
|
||||||
// itsdangerous URLSafeTimedSerializer(SECRET_KEY) (itsdangerous 2.2):
|
// itsdangerous URLSafeTimedSerializer(SECRET_KEY) (itsdangerous 2.2):
|
||||||
// - HMAC-SHA1 signature
|
// - HMAC-SHA1 signature
|
||||||
|
|
@ -93,6 +117,50 @@ func verifySessionCookie(secretKey, token string) *sessionUser {
|
||||||
return &sessionUser{Username: data.U, IsAdmin: data.A}
|
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) {
|
func decodeItsdangerousPayload(p string) ([]byte, error) {
|
||||||
compressed := strings.HasPrefix(p, ".")
|
compressed := strings.HasPrefix(p, ".")
|
||||||
if compressed {
|
if compressed {
|
||||||
|
|
@ -130,7 +198,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||||
}
|
}
|
||||||
if c, err := r.Cookie("session"); err == nil {
|
if c, err := r.Cookie("session"); err == nil {
|
||||||
if u := verifySessionCookie(s.secretKey, c.Value); u != nil {
|
if u := verifySessionCookie(s.secretKey, c.Value); u != nil {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r.WithContext(withUser(r.Context(), u)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ type Ingestor struct {
|
||||||
vitalPeerState map[string]map[string]any
|
vitalPeerState map[string]map[string]any
|
||||||
|
|
||||||
plugins *pluginRegistry // for share_* fan-out + plugin_connected status
|
plugins *pluginRegistry // for share_* fan-out + plugin_connected status
|
||||||
|
|
||||||
|
invFwd *invForwarder // inventory forwarding (cutover only; nil in shadow/read)
|
||||||
|
aclog *aclogPoster // death/idle Discord alerts (cutover only; nil otherwise)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIngestor(pool *pgxpool.Pool, log *slog.Logger, broadcast func(map[string]any), plugins *pluginRegistry) *Ingestor {
|
func newIngestor(pool *pgxpool.Pool, log *slog.Logger, broadcast func(map[string]any), plugins *pluginRegistry) *Ingestor {
|
||||||
|
|
@ -71,6 +74,17 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
|
||||||
switch {
|
switch {
|
||||||
case t == "telemetry" || (t == "" && hasTelemetryShape(data)):
|
case t == "telemetry" || (t == "" && hasTelemetryShape(data)):
|
||||||
i.handleTelemetry(ctx, data)
|
i.handleTelemetry(ctx, data)
|
||||||
|
// Python broadcasts telemetry as a TYPELESS snapshot (snap.dict()); the
|
||||||
|
// browser intentionally ignores typeless messages (useLiveData drops
|
||||||
|
// `if (!msg.type) return`) and takes player data from the 5s /live poll
|
||||||
|
// instead. Broadcasting it WITH a type makes the UI overwrite the
|
||||||
|
// /live-derived telemetry (which has total_kills/total_rares/session_rares)
|
||||||
|
// with the raw plugin payload (which lacks them), flapping those counters
|
||||||
|
// 0<->value. Strip the type to match.
|
||||||
|
if i.broadcast != nil {
|
||||||
|
i.broadcast(stripType(data))
|
||||||
|
}
|
||||||
|
return
|
||||||
case t == "rare":
|
case t == "rare":
|
||||||
i.handleRare(ctx, data)
|
i.handleRare(ctx, data)
|
||||||
case t == "portal":
|
case t == "portal":
|
||||||
|
|
@ -91,6 +105,18 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
|
||||||
i.handleDungeonMap(data)
|
i.handleDungeonMap(data)
|
||||||
case t == "combat_stats":
|
case t == "combat_stats":
|
||||||
i.handleCombatStats(ctx, data)
|
i.handleCombatStats(ctx, data)
|
||||||
|
case t == "full_inventory":
|
||||||
|
// Forward the full snapshot to the inventory service; not browser-broadcast.
|
||||||
|
if i.invFwd != nil {
|
||||||
|
i.invFwd.forwardFullInventory(data)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case t == "inventory_delta":
|
||||||
|
// Fire-and-forget forward; the forwarder broadcasts the enriched delta.
|
||||||
|
if i.invFwd != nil {
|
||||||
|
i.invFwd.handleInventoryDelta(data)
|
||||||
|
}
|
||||||
|
return
|
||||||
case t == "share_subscribe":
|
case t == "share_subscribe":
|
||||||
i.handleShareSubscribe(data)
|
i.handleShareSubscribe(data)
|
||||||
case t == "share_unsubscribe":
|
case t == "share_unsubscribe":
|
||||||
|
|
@ -108,6 +134,18 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripType returns a shallow copy of the message without its "type" key, so the
|
||||||
|
// browser treats it as a typeless snapshot (and ignores it, deferring to /live).
|
||||||
|
func stripType(data map[string]any) map[string]any {
|
||||||
|
cp := make(map[string]any, len(data))
|
||||||
|
for k, v := range data {
|
||||||
|
if k != "type" {
|
||||||
|
cp[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
func hasTelemetryShape(d map[string]any) bool {
|
func hasTelemetryShape(d map[string]any) bool {
|
||||||
_, a := d["session_id"]
|
_, a := d["session_id"]
|
||||||
_, b := d["ew"]
|
_, b := d["ew"]
|
||||||
|
|
@ -312,8 +350,21 @@ func (i *Ingestor) handleVitals(data map[string]any) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Death detection (discord alert) is intentionally omitted in shadow mode —
|
// Death detection (main.py:3419): vitae crossing 0 -> >0. Only in cutover
|
||||||
// it would duplicate the production alert. The live overlay still updates.
|
// (i.aclog != nil); in shadow mode it stays off to avoid duplicating the
|
||||||
|
// production alert.
|
||||||
|
if i.aclog != nil {
|
||||||
|
i.mu.RLock()
|
||||||
|
prev := i.liveVitals[name]
|
||||||
|
i.mu.RUnlock()
|
||||||
|
var prevVitae float64
|
||||||
|
if prev != nil {
|
||||||
|
prevVitae = toFloat(prev["vitae"])
|
||||||
|
}
|
||||||
|
if newVitae := toFloat(data["vitae"]); prevVitae == 0 && newVitae > 0 {
|
||||||
|
i.aclog.maybeDeath(name, newVitae)
|
||||||
|
}
|
||||||
|
}
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
i.liveVitals[name] = data
|
i.liveVitals[name] = data
|
||||||
i.mu.Unlock()
|
i.mu.Unlock()
|
||||||
|
|
@ -428,25 +479,22 @@ func nstr(v any) any {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// nint/nfloat return a typed number or nil (for nullable columns), coercing
|
||||||
|
// string-encoded numbers the plugin sends (see coerceNum).
|
||||||
func nint(v any) any {
|
func nint(v any) any {
|
||||||
switch x := v.(type) {
|
if f, ok := coerceNum(v); ok {
|
||||||
case float64:
|
return int64(f)
|
||||||
return int64(x)
|
|
||||||
case int:
|
|
||||||
return int64(x)
|
|
||||||
case int64:
|
|
||||||
return x
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func nfloat(v any) any {
|
func nfloat(v any) any {
|
||||||
if f, ok := v.(float64); ok {
|
if f, ok := coerceNum(v); ok {
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func toFloatOr(v any, def float64) float64 {
|
func toFloatOr(v any, def float64) float64 {
|
||||||
if f, ok := v.(float64); ok {
|
if f, ok := coerceNum(v); ok {
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
return def
|
return def
|
||||||
|
|
|
||||||
144
go-services/tracker-go/inventory_forward.go
Normal file
144
go-services/tracker-go/inventory_forward.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// invForwarder forwards plugin inventory events to the inventory service,
|
||||||
|
// porting main.py's _forward_to_inventory_service / _handle_inventory_delta.
|
||||||
|
// Only active in cutover (write) mode; nil in shadow/read-only mode, where the
|
||||||
|
// plugin firehose never carries inventory anyway.
|
||||||
|
//
|
||||||
|
// full_inventory -> POST {url}/process-inventory (full replace)
|
||||||
|
// inventory_delta add/update -> POST {url}/inventory/{char}/item
|
||||||
|
// inventory_delta remove -> DELETE {url}/inventory/{char}/item/{item_id}
|
||||||
|
//
|
||||||
|
// Deltas are fire-and-forget (never block the /ws/position read loop), serialized
|
||||||
|
// per-character (so a char's rapid deltas don't race the inventory DELETE+INSERT),
|
||||||
|
// and globally capped at 8 concurrent forwards.
|
||||||
|
type invForwarder struct {
|
||||||
|
url string
|
||||||
|
client *http.Client
|
||||||
|
sem chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
locks map[string]*sync.Mutex
|
||||||
|
log *slog.Logger
|
||||||
|
broadcast func(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInvForwarder(rawURL string, log *slog.Logger, broadcast func(map[string]any)) *invForwarder {
|
||||||
|
return &invForwarder{
|
||||||
|
url: strings.TrimRight(rawURL, "/"),
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
sem: make(chan struct{}, 8),
|
||||||
|
locks: map[string]*sync.Mutex{},
|
||||||
|
log: log,
|
||||||
|
broadcast: broadcast,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *invForwarder) charLock(name string) *sync.Mutex {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
l := f.locks[name]
|
||||||
|
if l == nil {
|
||||||
|
l = &sync.Mutex{}
|
||||||
|
f.locks[name] = l
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardFullInventory POSTs a full inventory snapshot (full replace). Runs
|
||||||
|
// inline on the /ws/position handler — main.py awaits _store_inventory too.
|
||||||
|
func (f *invForwarder) forwardFullInventory(data map[string]any) {
|
||||||
|
char := toStr(data["character_name"])
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"character_name": char,
|
||||||
|
"timestamp": data["timestamp"],
|
||||||
|
"items": data["items"],
|
||||||
|
})
|
||||||
|
resp, err := f.client.Post(f.url+"/process-inventory", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
f.log.Error("full_inventory forward failed", "err", err, "char", char)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer drain(resp)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
f.log.Warn("inventory service error (full_inventory)", "status", resp.StatusCode, "char", char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInventoryDelta forwards a single add/update/remove. Fire-and-forget.
|
||||||
|
func (f *invForwarder) handleInventoryDelta(data map[string]any) {
|
||||||
|
go func() {
|
||||||
|
char := toStr(data["character_name"])
|
||||||
|
lock := f.charLock(char)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
f.sem <- struct{}{}
|
||||||
|
defer func() { <-f.sem }()
|
||||||
|
|
||||||
|
out := data
|
||||||
|
switch toStr(data["action"]) {
|
||||||
|
case "remove":
|
||||||
|
if itemID := data["item_id"]; itemID != nil {
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete,
|
||||||
|
fmt.Sprintf("%s/inventory/%s/item/%v", f.url, url.PathEscape(char), itemID), nil)
|
||||||
|
if resp, err := f.client.Do(req); err != nil {
|
||||||
|
f.log.Warn("inventory delta remove failed", "err", err, "char", char)
|
||||||
|
} else {
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
f.log.Warn("inventory service error (delta remove)", "status", resp.StatusCode, "char", char)
|
||||||
|
}
|
||||||
|
drain(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "add", "update":
|
||||||
|
if item := data["item"]; item != nil {
|
||||||
|
b, _ := json.Marshal(item)
|
||||||
|
resp, err := f.client.Post(fmt.Sprintf("%s/inventory/%s/item", f.url, url.PathEscape(char)),
|
||||||
|
"application/json", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
f.log.Warn("inventory delta add/update failed", "err", err, "char", char)
|
||||||
|
} else {
|
||||||
|
if resp.StatusCode < 400 {
|
||||||
|
// Re-broadcast the enriched item the service returns.
|
||||||
|
var r map[string]any
|
||||||
|
if json.NewDecoder(resp.Body).Decode(&r) == nil {
|
||||||
|
if enriched, ok := r["item"]; ok && enriched != nil {
|
||||||
|
out = map[string]any{
|
||||||
|
"type": "inventory_delta",
|
||||||
|
"action": toStr(data["action"]),
|
||||||
|
"character_name": char,
|
||||||
|
"item": enriched,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f.log.Warn("inventory service error (delta add/update)", "status", resp.StatusCode, "char", char)
|
||||||
|
}
|
||||||
|
drain(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.broadcast != nil {
|
||||||
|
f.broadcast(out)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func drain(resp *http.Response) {
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ type Server struct {
|
||||||
ingestor *Ingestor // non-nil only in ingest/shadow mode
|
ingestor *Ingestor // non-nil only in ingest/shadow mode
|
||||||
hub *Hub // browser /ws/live fan-out
|
hub *Hub // browser /ws/live fan-out
|
||||||
plugins *pluginRegistry
|
plugins *pluginRegistry
|
||||||
|
loginLimiter *loginLimiter
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +56,13 @@ func main() {
|
||||||
runCombatMergeCLI()
|
runCombatMergeCLI()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// `tracker-go issue-cookie <username> <is_admin> <secret_key>` prints a
|
||||||
|
// session token — a hook to cross-check itsdangerous cookie interop with the
|
||||||
|
// Python service.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "issue-cookie" {
|
||||||
|
runIssueCookieCLI()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
@ -68,6 +76,7 @@ func main() {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
cache: newLiveCache(),
|
cache: newLiveCache(),
|
||||||
totals: newTotalsCache(),
|
totals: newTotalsCache(),
|
||||||
|
loginLimiter: newLoginLimiter(),
|
||||||
staticDir: cfg.StaticDir,
|
staticDir: cfg.StaticDir,
|
||||||
secretKey: cfg.SecretKey,
|
secretKey: cfg.SecretKey,
|
||||||
sharedSecret: cfg.SharedSecret,
|
sharedSecret: cfg.SharedSecret,
|
||||||
|
|
@ -103,22 +112,38 @@ func main() {
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
srv.pool = pool
|
srv.pool = pool
|
||||||
|
|
||||||
// Ingest/shadow mode owns its own DB: create the schema on first run.
|
// Write mode (shadow OR cutover) owns the ingest path; read-only mode
|
||||||
|
// (parallel read API) skips all of this.
|
||||||
if !cfg.ReadOnly {
|
if !cfg.ReadOnly {
|
||||||
|
// Schema init only when we own a fresh DB. In cutover (reusing the
|
||||||
|
// production DB) SKIP_SCHEMA_INIT keeps us from running ANY DDL.
|
||||||
|
if !cfg.SkipSchemaInit {
|
||||||
schemaCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
schemaCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
initSchema(schemaCtx, pool, logger)
|
initSchema(schemaCtx, pool, logger)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shadow ingest: replay the Python /ws/live firehose into our handlers.
|
|
||||||
if cfg.IngestWS != "" {
|
|
||||||
if cfg.ReadOnly {
|
|
||||||
logger.Error("SHADOW_INGEST_WS set but READ_ONLY=true; refusing to ingest into the production DB")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
srv.ingestor = newIngestor(pool, logger, srv.hub.broadcast, srv.plugins)
|
srv.ingestor = newIngestor(pool, logger, srv.hub.broadcast, srv.plugins)
|
||||||
|
|
||||||
|
if cfg.IngestWS != "" {
|
||||||
|
// Shadow: replay the Python /ws/live firehose. Inventory forwarding
|
||||||
|
// + Discord alerts stay OFF (would double production writes/alerts;
|
||||||
|
// inventory isn't in the firehose anyway).
|
||||||
go srv.runShadowConsumer(ctx, cfg.IngestWS)
|
go srv.runShadowConsumer(ctx, cfg.IngestWS)
|
||||||
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
|
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
|
||||||
|
} else {
|
||||||
|
// Cutover: the real plugin connects to /ws/position. Forward
|
||||||
|
// inventory to the inventory service and post death/idle alerts.
|
||||||
|
srv.ingestor.invFwd = newInvForwarder(cfg.InventoryURL, logger, srv.hub.broadcast)
|
||||||
|
if cfg.DiscordACLog != "" {
|
||||||
|
srv.ingestor.aclog = newACLogPoster(cfg.DiscordACLog, logger)
|
||||||
|
go srv.ingestor.aclog.runIdleLoop(ctx, pool)
|
||||||
|
}
|
||||||
|
logger.Info("cutover ingest enabled", "inventory_url", cfg.InventoryURL, "aclog", cfg.DiscordACLog != "")
|
||||||
|
}
|
||||||
|
} else if cfg.IngestWS != "" {
|
||||||
|
logger.Error("SHADOW_INGEST_WS set but READ_ONLY=true; refusing to ingest into the production DB")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
go srv.runCacheLoop(ctx)
|
go srv.runCacheLoop(ctx)
|
||||||
|
|
@ -166,6 +191,8 @@ type config struct {
|
||||||
SharedSecret string // plugin /ws/position auth
|
SharedSecret string // plugin /ws/position auth
|
||||||
SharedSecretLegacy string // plugin auth rotation fallback
|
SharedSecretLegacy string // plugin auth rotation fallback
|
||||||
IngestWS string // optional: a /ws/live URL to shadow-ingest from (Python tracker)
|
IngestWS string // optional: a /ws/live URL to shadow-ingest from (Python tracker)
|
||||||
|
SkipSchemaInit bool // cutover: trust the existing prod schema, run no DDL
|
||||||
|
DiscordACLog string // #aclog webhook for death/idle alerts (cutover only)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
|
|
@ -179,6 +206,8 @@ func loadConfig() config {
|
||||||
SharedSecret: os.Getenv("SHARED_SECRET"),
|
SharedSecret: os.Getenv("SHARED_SECRET"),
|
||||||
SharedSecretLegacy: os.Getenv("SHARED_SECRET_LEGACY"),
|
SharedSecretLegacy: os.Getenv("SHARED_SECRET_LEGACY"),
|
||||||
IngestWS: os.Getenv("SHADOW_INGEST_WS"),
|
IngestWS: os.Getenv("SHADOW_INGEST_WS"),
|
||||||
|
SkipSchemaInit: envOr("SKIP_SCHEMA_INIT", "false") == "true",
|
||||||
|
DiscordACLog: os.Getenv("DISCORD_ACLOG_WEBHOOK"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +259,28 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||||
|
|
||||||
// Inventory-service reverse proxies.
|
// Inventory-service reverse proxies.
|
||||||
s.registerProxyRoutes(mux)
|
s.registerProxyRoutes(mux)
|
||||||
|
|
||||||
|
// Website layer: login/logout + icons + static frontend (cutover).
|
||||||
|
mux.HandleFunc("GET /login", s.handleLoginGet)
|
||||||
|
mux.HandleFunc("POST /login", s.handleLoginPost)
|
||||||
|
mux.HandleFunc("GET /logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("GET /icons/{filename}", s.handleIcon)
|
||||||
|
|
||||||
|
// Admin user management.
|
||||||
|
mux.HandleFunc("GET /admin/users", s.handleAdminPage)
|
||||||
|
mux.HandleFunc("GET /api-admin/users", s.handleListUsers)
|
||||||
|
mux.HandleFunc("POST /api-admin/users", s.handleCreateUser)
|
||||||
|
mux.HandleFunc("PATCH /api-admin/users/{user_id}", s.handleUpdateUser)
|
||||||
|
mux.HandleFunc("DELETE /api-admin/users/{user_id}", s.handleDeleteUser)
|
||||||
|
|
||||||
|
// Issue board write side (GET /issues is registered above).
|
||||||
|
mux.HandleFunc("POST /issues", s.handleAddIssue)
|
||||||
|
mux.HandleFunc("PATCH /issues/{issue_id}", s.handleUpdateIssue)
|
||||||
|
mux.HandleFunc("POST /issues/{issue_id}/comments", s.handleAddComment)
|
||||||
|
mux.HandleFunc("DELETE /issues/{issue_id}", s.handleDeleteIssue)
|
||||||
|
// Catch-all: serve the static frontend (SPA). Registered last; every
|
||||||
|
// specific route above is more specific, so this only handles the rest.
|
||||||
|
mux.HandleFunc("GET /", s.handleStatic)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,13 @@ func (s *Server) loadIssues() []any {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /me — current user. Phase 1 has no session-cookie verification yet, so
|
// GET /me — current user from the session (main.py:1455). Internal-trust
|
||||||
// (like the Python service for an unauthenticated request) this is 401. The
|
// loopback requests carry no user identity, so they get 401 too.
|
||||||
// loopback internal-trust path carries no user identity. (main.py:1455)
|
|
||||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
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"})
|
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})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,38 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// coerceNum converts a JSON value to a float64, parsing string-encoded numbers.
|
||||||
|
// The plugin sends several telemetry fields as strings (kills_per_hour, deaths,
|
||||||
|
// total_deaths, prismatic_taper_count via .ToString()); Python's pydantic
|
||||||
|
// coerced them, so Go must too or it writes null/0 (causing the live counters
|
||||||
|
// to flap 0<->value between the WS broadcast and the DB-derived /live poll).
|
||||||
|
func coerceNum(v any) (float64, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return x, true
|
||||||
|
case float32:
|
||||||
|
return float64(x), true
|
||||||
|
case int:
|
||||||
|
return float64(x), true
|
||||||
|
case int32:
|
||||||
|
return float64(x), true
|
||||||
|
case int64:
|
||||||
|
return float64(x), true
|
||||||
|
case string:
|
||||||
|
s := strings.TrimSpace(x)
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
return f, err == nil
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
// reqCtx returns a child of the request context with a query timeout.
|
// reqCtx returns a child of the request context with a query timeout.
|
||||||
func reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
|
func reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
|
||||||
return context.WithTimeout(r.Context(), 15*time.Second)
|
return context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
|
@ -323,45 +352,16 @@ func join(parts []string, sep string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func toFloat(v any) float64 {
|
func toFloat(v any) float64 {
|
||||||
switch x := v.(type) {
|
f, _ := coerceNum(v)
|
||||||
case float64:
|
return f
|
||||||
return x
|
|
||||||
case float32:
|
|
||||||
return float64(x)
|
|
||||||
case int64:
|
|
||||||
return float64(x)
|
|
||||||
case int32:
|
|
||||||
return float64(x)
|
|
||||||
case int:
|
|
||||||
return float64(x)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toInt(v any) int {
|
func toInt(v any) int {
|
||||||
switch x := v.(type) {
|
f, _ := coerceNum(v)
|
||||||
case int64:
|
return int(f)
|
||||||
return int(x)
|
|
||||||
case int32:
|
|
||||||
return int(x)
|
|
||||||
case int:
|
|
||||||
return x
|
|
||||||
case float64:
|
|
||||||
return int(x)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toInt64(v any) int64 {
|
func toInt64(v any) int64 {
|
||||||
switch x := v.(type) {
|
f, _ := coerceNum(v)
|
||||||
case int64:
|
return int64(f)
|
||||||
return x
|
|
||||||
case int32:
|
|
||||||
return int64(x)
|
|
||||||
case int:
|
|
||||||
return int64(x)
|
|
||||||
case float64:
|
|
||||||
return int64(x)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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"}))
|
||||||
|
}
|
||||||
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})
|
||||||
|
}
|
||||||
192
go-services/tracker-go/website_issues.go
Normal file
192
go-services/tracker-go/website_issues.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
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"})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue