ai-code-battle/cmd/acb-api/server.go
jedarden 7df2fad568 feat(api): wire voteLtr rate limiter for upvote endpoint (§13.6)
Add dedicated 10/hour-per-IP rate limiter for POST /api/feedback/{id}/upvote,
separate from the 20/hour feedback submission limiter. Wired in main.go init,
server_test.go helper, and RegisterRoutes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:37:34 -04:00

1536 lines
48 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/aicodebattle/acb/metrics"
"github.com/aicodebattle/acb/ratelimit"
"github.com/redis/go-redis/v9"
)
// Server is the v1 API server for AI Code Battle.
// Provides bot registration, job coordination, replay serving,
// bot profiles, leaderboards, and UI feedback ingestion.
type Server struct {
cfg Config
db *sql.DB
rdb *redis.Client
regLimiter *ratelimit.Limiter // 5/hour per IP
feedbackLtr *ratelimit.Limiter // 20/hour per IP
predictLtr *ratelimit.Limiter // 60/hour per IP
submitLtr *ratelimit.Limiter // 5/day per bot_id
voteLtr *ratelimit.Limiter // 10/hour per IP
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
// Health endpoints (no rate limit)
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /ready", s.handleReady)
// Bot registration — 5/hour per IP
regMW := s.regLimiter.Middleware(ipKey, func() {
metrics.RateLimitHits.WithLabelValues("register").Inc()
})
mux.HandleFunc("POST /api/register", regMW(http.HandlerFunc(s.handleRegister)).ServeHTTP)
// Bot profile edit — toggle debug_public (authenticated by shared_secret)
mux.HandleFunc("PATCH /api/bot/", s.handleBotPatch)
// Bot key rotation + optional retirement — §8.5
mux.HandleFunc("POST /api/rotate-key", s.handleRotateKey)
// Job coordination (for workers — authenticated, no public rate limit)
mux.HandleFunc("GET /api/job", s.handleGetJob)
// Job result submission — per-worker 5/day limit
submitMW := s.submitLtr.Middleware(botIDKey(), func() {
metrics.RateLimitHits.WithLabelValues("submit").Inc()
})
mux.HandleFunc("POST /api/job/", submitMW(http.HandlerFunc(s.handleJobResult)).ServeHTTP)
// Replay serving (read-only, no rate limit)
mux.HandleFunc("GET /api/replay/", s.handleGetReplay)
// Bot profiles and leaderboard (read-only, no rate limit)
mux.HandleFunc("GET /api/bot/", s.handleGetBot)
mux.HandleFunc("GET /api/bots", s.handleListBots)
// Community replay feedback — 20/hour per IP
fbMW := s.feedbackLtr.Middleware(ipKey, func() {
metrics.RateLimitHits.WithLabelValues("feedback").Inc()
})
voteMW := s.voteLtr.Middleware(ipKey, func() {
metrics.RateLimitHits.WithLabelValues("vote").Inc()
})
mux.HandleFunc("POST /api/feedback", fbMW(http.HandlerFunc(s.handleUIFeedback)).ServeHTTP)
mux.HandleFunc("GET /api/feedback/", s.handleGetFeedback)
mux.HandleFunc("POST /api/feedback/", voteMW(http.HandlerFunc(s.handleFeedbackUpvote)).ServeHTTP)
// Predictions — 60/hour per IP
predMW := s.predictLtr.Middleware(ipKey, func() {
metrics.RateLimitHits.WithLabelValues("predict").Inc()
})
mux.HandleFunc("POST /api/predict", predMW(http.HandlerFunc(s.handlePredict)).ServeHTTP)
mux.HandleFunc("GET /api/predictions/open", s.handleOpenPredictions)
mux.HandleFunc("GET /api/predictions/history", s.handlePredictionHistory)
}
// ipKey extracts the client IP from the request for rate limiting.
// Respects X-Forwarded-For when present (behind reverse proxy).
func ipKey(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}
// botIDKey extracts a rate-limit key for the job submission endpoint.
// Workers submit results authenticated by API key, so we key by worker IP
// to enforce the per-worker submission rate limit (max 5/day).
func botIDKey() func(*http.Request) string {
return func(r *http.Request) string {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return "worker:" + host
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// handleRegister handles POST /api/register
// Request body: {"name": "...", "owner": "...", "endpoint_url": "..."}
// Response: {"bot_id": "...", "shared_secret": "..."}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Name string `json:"name"`
Owner string `json:"owner"`
EndpointURL string `json:"endpoint_url"`
DebugPublic bool `json:"debug_public"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate required fields
if req.Name == "" || req.Owner == "" || req.EndpointURL == "" {
writeError(w, http.StatusBadRequest, "name, owner, and endpoint_url are required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Check if name is already taken
var existingID string
err := s.db.QueryRowContext(ctx, "SELECT bot_id FROM bots WHERE name = $1", req.Name).Scan(&existingID)
if err == nil {
writeError(w, http.StatusConflict, fmt.Sprintf("bot name '%s' is already taken", req.Name))
return
} else if err != sql.ErrNoRows {
log.Printf("database error checking bot name: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Generate bot ID and shared secret
botID, err := generateID("b_", 6)
if err != nil {
log.Printf("failed to generate bot ID: %v", err)
writeError(w, http.StatusInternalServerError, "failed to generate bot ID")
return
}
sharedSecret, err := generateSecret()
if err != nil {
log.Printf("failed to generate secret: %v", err)
writeError(w, http.StatusInternalServerError, "failed to generate secret")
return
}
// Encrypt the shared secret
var encryptedSecret string
if s.cfg.EncryptionKey != "" {
encryptedSecret, err = encryptSecret(sharedSecret, s.cfg.EncryptionKey)
if err != nil {
log.Printf("failed to encrypt secret: %v", err)
writeError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
} else {
// If no encryption key configured, store plaintext (not recommended for production)
encryptedSecret = sharedSecret
}
// Validate bot is reachable by sending a health check
if err := s.validateBotEndpoint(ctx, req.EndpointURL); err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("bot endpoint validation failed: %v", err))
return
}
// Insert bot into database
_, err = s.db.ExecContext(ctx, `
INSERT INTO bots (bot_id, name, owner, endpoint_url, shared_secret, status, debug_public)
VALUES ($1, $2, $3, $4, $5, 'active', $6)
`, botID, req.Name, req.Owner, req.EndpointURL, encryptedSecret, req.DebugPublic)
if err != nil {
log.Printf("failed to insert bot: %v", err)
writeError(w, http.StatusInternalServerError, "failed to register bot")
return
}
log.Printf("registered bot %s (name=%s, owner=%s)", botID, req.Name, req.Owner)
writeJSON(w, http.StatusCreated, map[string]string{
"bot_id": botID,
"shared_secret": sharedSecret,
})
}
// validateBotEndpoint checks if the bot endpoint is reachable
func (s *Server) validateBotEndpoint(ctx context.Context, endpointURL string) error {
// Remove trailing slash for consistency
endpointURL = strings.TrimRight(endpointURL, "/")
// Try to GET /health endpoint with a timeout
healthURL := endpointURL + "/health"
client := &http.Client{Timeout: time.Duration(s.cfg.BotTimeoutSecs) * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return fmt.Errorf("invalid endpoint URL: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("endpoint unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("health check returned status %d", resp.StatusCode)
}
return nil
}
// handleGetJob handles GET /api/job
// Workers poll this endpoint to get the next pending match job.
// Requires Bearer token authentication (worker API key).
// Response: job JSON or empty if no jobs available.
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Authenticate worker
if !s.authenticateWorker(r) {
writeError(w, http.StatusUnauthorized, "invalid or missing worker API key")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Query for the next pending job
var job struct {
JobID string `json:"job_id"`
MatchID string `json:"match_id"`
ConfigJSON json.RawMessage `json:"config_json"`
}
err := s.db.QueryRowContext(ctx, `
SELECT job_id, match_id, config_json
FROM jobs
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`).Scan(&job.JobID, &job.MatchID, &job.ConfigJSON)
if err == sql.ErrNoRows {
// No pending jobs
writeJSON(w, http.StatusOK, map[string]string{"status": "no_jobs"})
return
} else if err != nil {
log.Printf("database error getting job: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Parse config_json to get match details
var config struct {
MapID string `json:"map_id"`
MapSeed int64 `json:"map_seed"`
BotIDs []string `json:"bot_ids"`
PlayerSlots []int `json:"player_slots"`
}
if err := json.Unmarshal(job.ConfigJSON, &config); err != nil {
log.Printf("failed to parse job config: %v", err)
writeError(w, http.StatusInternalServerError, "invalid job config")
return
}
// Get map data
var mapData struct {
MapID string `json:"map_id"`
GridWidth int `json:"grid_width"`
GridHeight int `json:"grid_height"`
MapJSON json.RawMessage `json:"map_json"`
}
err = s.db.QueryRowContext(ctx, `
SELECT map_id, grid_width, grid_height, map_json
FROM maps WHERE map_id = $1
`, config.MapID).Scan(&mapData.MapID, &mapData.GridWidth, &mapData.GridHeight, &mapData.MapJSON)
if err != nil {
log.Printf("failed to get map: %v", err)
writeError(w, http.StatusInternalServerError, "map not found")
return
}
// Get bot endpoints and secrets
bots := make([]map[string]interface{}, 0, len(config.BotIDs))
for _, botID := range config.BotIDs {
var endpointURL, encryptedSecret string
err := s.db.QueryRowContext(ctx, `
SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1
`, botID).Scan(&endpointURL, &encryptedSecret)
if err != nil {
log.Printf("failed to get bot %s: %v", botID, err)
writeError(w, http.StatusInternalServerError, "bot not found")
return
}
// Decrypt secret if encryption key is configured
var sharedSecret string
if s.cfg.EncryptionKey != "" {
sharedSecret, err = decryptSecret(encryptedSecret, s.cfg.EncryptionKey)
if err != nil {
log.Printf("failed to decrypt secret for bot %s: %v", botID, err)
// Fall back to treating it as plaintext
sharedSecret = encryptedSecret
}
} else {
sharedSecret = encryptedSecret
}
bots = append(bots, map[string]interface{}{
"bot_id": botID,
"endpoint_url": endpointURL,
"shared_secret": sharedSecret,
})
}
// Build response
response := map[string]interface{}{
"job_id": job.JobID,
"match_id": job.MatchID,
"map_id": config.MapID,
"map_seed": config.MapSeed,
"map_width": mapData.GridWidth,
"map_height": mapData.GridHeight,
"map_json": mapData.MapJSON,
"bots": bots,
"player_slots": config.PlayerSlots,
}
writeJSON(w, http.StatusOK, response)
}
// handleJobResult handles POST /api/job/{id}/result
// Workers submit match results here.
// Requires Bearer token authentication.
// Request body: {"winner": "...", "turns": 123, "end_reason": "...", "scores": {...}, "replay": {...}}
func (s *Server) handleJobResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Authenticate worker
if !s.authenticateWorker(r) {
writeError(w, http.StatusUnauthorized, "invalid or missing worker API key")
return
}
// Extract job ID from path: /api/job/{id}/result
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 4 || pathParts[3] != "result" {
writeError(w, http.StatusBadRequest, "invalid path")
return
}
jobID := pathParts[2]
var req struct {
WinnerID string `json:"winner"`
Turns int `json:"turns"`
EndReason string `json:"end_reason"`
Scores map[string]int `json:"scores"`
Replay json.RawMessage `json:"replay"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
log.Printf("failed to begin transaction: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer tx.Rollback()
// Get match ID for this job
var matchID string
err = tx.QueryRowContext(ctx, "SELECT match_id FROM jobs WHERE job_id = $1", jobID).Scan(&matchID)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "job not found")
return
} else if err != nil {
log.Printf("failed to get job: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Update job status
_, err = tx.ExecContext(ctx, `
UPDATE jobs SET status = 'completed', completed_at = NOW() WHERE job_id = $1
`, jobID)
if err != nil {
log.Printf("failed to update job: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Determine winner player index
var winnerIndex *int
if req.WinnerID != "" {
var idx int
err := tx.QueryRowContext(ctx, `
SELECT player_slot FROM match_participants WHERE match_id = $1 AND bot_id = $2
`, matchID, req.WinnerID).Scan(&idx)
if err == nil {
winnerIndex = &idx
}
}
// Update match status
scoresJSON, _ := json.Marshal(req.Scores)
_, err = tx.ExecContext(ctx, `
UPDATE matches
SET status = 'completed', winner = $1, condition = $2, turn_count = $3, scores_json = $4, completed_at = NOW()
WHERE match_id = $5
`, winnerIndex, req.EndReason, req.Turns, scoresJSON, matchID)
if err != nil {
log.Printf("failed to update match: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Update participant scores
for botID, score := range req.Scores {
_, err = tx.ExecContext(ctx, `
UPDATE match_participants SET score = $1 WHERE match_id = $2 AND bot_id = $3
`, score, matchID, botID)
if err != nil {
log.Printf("failed to update participant score: %v", err)
}
}
// Note: Rating updates are handled by the worker separately via the rating endpoint
// or can be computed here if the ratings are provided in the request
// Resolve predictions for this match
if err := s.resolvePredictions(ctx, tx, matchID, req.WinnerID); err != nil {
log.Printf("failed to resolve predictions for match %s: %v", matchID, err)
}
if err := tx.Commit(); err != nil {
log.Printf("failed to commit transaction: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
log.Printf("completed job %s, match %s, winner %s", jobID, matchID, req.WinnerID)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleGetReplay handles GET /api/replay/{id}
// Serves replay JSON from R2 warm cache with B2 fallback.
func (s *Server) handleGetReplay(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Extract match ID from path: /api/replay/{id}
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/replay/"), "/")
if len(pathParts) == 0 || pathParts[0] == "" {
writeError(w, http.StatusBadRequest, "invalid match ID")
return
}
matchID := pathParts[0]
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// First, try to get from R2 warm cache
// This requires R2 credentials to be configured
replayData, err := s.fetchReplayFromR2(ctx, matchID)
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.WriteHeader(http.StatusOK)
w.Write(replayData)
return
}
log.Printf("R2 fetch failed for %s: %v", matchID, err)
// Fall back to B2 cold archive
replayData, err = s.fetchReplayFromB2(ctx, matchID)
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-ACB-Source", "b2")
w.WriteHeader(http.StatusOK)
w.Write(replayData)
return
}
log.Printf("B2 fetch also failed for %s: %v", matchID, err)
writeError(w, http.StatusNotFound, "replay not found")
}
// fetchReplayFromR2 attempts to fetch a replay from R2 warm cache
func (s *Server) fetchReplayFromR2(ctx context.Context, matchID string) ([]byte, error) {
// R2 endpoint and credentials would be configured via environment variables
r2Endpoint := "https://r2.aicodebattle.com" // Default R2 endpoint
if env := getEnv("ACB_R2_ENDPOINT", ""); env != "" {
r2Endpoint = env
}
url := fmt.Sprintf("%s/replays/%s.json.gz", r2Endpoint, matchID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("R2 returned status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// fetchReplayFromB2 attempts to fetch a replay from B2 cold archive
func (s *Server) fetchReplayFromB2(ctx context.Context, matchID string) ([]byte, error) {
// B2 endpoint and credentials would be configured via environment variables
b2Endpoint := "https://b2.aicodebattle.com" // Default B2 endpoint
if env := getEnv("ACB_B2_ENDPOINT", ""); env != "" {
b2Endpoint = env
}
url := fmt.Sprintf("%s/replays/%s.json.gz", b2Endpoint, matchID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("B2 returned status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// handleGetBot handles GET /api/bot/{id}
// Returns bot profile JSON including rating, record, and metadata.
func (s *Server) handleGetBot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Extract bot ID from path: /api/bot/{id}
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/bot/"), "/")
if len(pathParts) == 0 || pathParts[0] == "" {
writeError(w, http.StatusBadRequest, "invalid bot ID")
return
}
botID := pathParts[0]
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Get bot details
var bot struct {
BotID string `json:"bot_id"`
Name string `json:"name"`
Owner string `json:"owner"`
Status string `json:"status"`
RatingMu float64 `json:"rating_mu"`
RatingPhi float64 `json:"rating_phi"`
Evolved bool `json:"evolved"`
Island *string `json:"island,omitempty"`
Generation *int `json:"generation,omitempty"`
ParentIDs *string `json:"parent_ids,omitempty"`
DebugPublic bool `json:"debug_public"`
CreatedAt string `json:"created_at"`
LastActive *string `json:"last_active,omitempty"`
}
err := s.db.QueryRowContext(ctx, `
SELECT bot_id, name, owner, status, rating_mu, rating_phi,
evolved, island, generation, parent_ids,
to_char(created_at, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as created_at,
to_char(last_active, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as last_active
FROM bots WHERE bot_id = $1
`, botID).Scan(
&bot.BotID, &bot.Name, &bot.Owner, &bot.Status,
&bot.RatingMu, &bot.RatingPhi, &bot.Evolved,
&bot.Island, &bot.Generation, &bot.ParentIDs,
&bot.CreatedAt, &bot.LastActive,
)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "bot not found")
return
} else if err != nil {
log.Printf("database error getting bot: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Calculate win/loss record
var wins, losses int
err = s.db.QueryRowContext(ctx, `
SELECT
COUNT(*) FILTER (WHERE mp.bot_id = $1 AND m.winner = (
SELECT player_slot FROM match_participants WHERE match_id = m.match_id AND bot_id = $1
)) as wins,
COUNT(*) FILTER (WHERE mp.bot_id = $1 AND m.winner IS NOT NULL AND m.winner != (
SELECT player_slot FROM match_participants WHERE match_id = m.match_id AND bot_id = $1
)) as losses
FROM match_participants mp
JOIN matches m ON mp.match_id = m.match_id
WHERE mp.bot_id = $1 AND m.status = 'completed'
`, botID).Scan(&wins, &losses)
if err != nil {
log.Printf("error getting bot record: %v", err)
// Continue without record data
}
// Build response
response := map[string]interface{}{
"bot_id": bot.BotID,
"name": bot.Name,
"owner": bot.Owner,
"status": bot.Status,
"rating": bot.RatingMu - 2*bot.RatingPhi, // Conservative rating estimate
"rating_mu": bot.RatingMu,
"rating_phi": bot.RatingPhi,
"evolved": bot.Evolved,
"island": bot.Island,
"generation": bot.Generation,
"parent_ids": bot.ParentIDs,
"created_at": bot.CreatedAt,
"last_active": bot.LastActive,
"record": map[string]int{
"wins": wins,
"losses": losses,
},
}
writeJSON(w, http.StatusOK, response)
}
// handleBotPatch handles PATCH /api/bot/{id}
// Allows owners to toggle debug_public using their shared_secret for auth.
func (s *Server) handleBotPatch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Extract bot ID from path: /api/bot/{id}
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/bot/"), "/")
if len(pathParts) == 0 || pathParts[0] == "" {
writeError(w, http.StatusBadRequest, "invalid bot ID")
return
}
botID := pathParts[0]
var req struct {
DebugPublic *bool `json:"debug_public"`
APISecret string `json:"api_secret"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.DebugPublic == nil {
writeError(w, http.StatusBadRequest, "debug_public field is required")
return
}
if req.APISecret == "" {
writeError(w, http.StatusUnauthorized, "api_secret is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Verify the secret matches
var encryptedSecret string
err := s.db.QueryRowContext(ctx, `SELECT shared_secret FROM bots WHERE bot_id = $1`, botID).Scan(&encryptedSecret)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "bot not found")
return
} else if err != nil {
log.Printf("database error getting bot secret: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Decrypt stored secret for comparison
var storedSecret string
if s.cfg.EncryptionKey != "" {
storedSecret, err = decryptSecret(encryptedSecret, s.cfg.EncryptionKey)
if err != nil {
storedSecret = encryptedSecret
}
} else {
storedSecret = encryptedSecret
}
if storedSecret != req.APISecret {
writeError(w, http.StatusUnauthorized, "invalid api_secret")
return
}
// Update debug_public
_, err = s.db.ExecContext(ctx, `UPDATE bots SET debug_public = $1 WHERE bot_id = $2`, *req.DebugPublic, botID)
if err != nil {
log.Printf("failed to update debug_public for bot %s: %v", botID, err)
writeError(w, http.StatusInternalServerError, "failed to update")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"bot_id": botID,
"debug_public": *req.DebugPublic,
})
}
// handleRotateKey handles POST /api/rotate-key (§8.5)
// Authenticates with the current shared_secret, generates a new 256-bit secret,
// encrypts it with AES-256-GCM, updates the bots table, and returns the new secret.
// Optionally retires the bot when called with retire: true.
func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
BotID string `json:"bot_id"`
Secret string `json:"shared_secret"`
Retire bool `json:"retire"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.BotID == "" || req.Secret == "" {
writeError(w, http.StatusBadRequest, "bot_id and shared_secret are required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Look up the bot and verify the current secret
var encryptedSecret string
var currentStatus string
err := s.db.QueryRowContext(ctx,
`SELECT shared_secret, status FROM bots WHERE bot_id = $1`, req.BotID,
).Scan(&encryptedSecret, &currentStatus)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "bot not found")
return
} else if err != nil {
log.Printf("database error getting bot %s: %v", req.BotID, err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Decrypt stored secret for comparison
var storedSecret string
if s.cfg.EncryptionKey != "" {
storedSecret, err = decryptSecret(encryptedSecret, s.cfg.EncryptionKey)
if err != nil {
storedSecret = encryptedSecret
}
} else {
storedSecret = encryptedSecret
}
if storedSecret != req.Secret {
writeError(w, http.StatusUnauthorized, "invalid shared_secret")
return
}
// A retired bot cannot rotate its key again
if currentStatus == "retired" {
writeError(w, http.StatusConflict, "bot is retired")
return
}
// Generate new 256-bit secret
newSecret, err := generateSecret()
if err != nil {
log.Printf("failed to generate new secret for bot %s: %v", req.BotID, err)
writeError(w, http.StatusInternalServerError, "failed to generate secret")
return
}
// Encrypt the new secret
var encryptedNew string
if s.cfg.EncryptionKey != "" {
encryptedNew, err = encryptSecret(newSecret, s.cfg.EncryptionKey)
if err != nil {
log.Printf("failed to encrypt new secret for bot %s: %v", req.BotID, err)
writeError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
} else {
encryptedNew = newSecret
}
// Update the bot: always rotate the secret; optionally set status to retired
if req.Retire {
_, err = s.db.ExecContext(ctx,
`UPDATE bots SET shared_secret = $1, status = 'retired', last_active = NOW() WHERE bot_id = $2`,
encryptedNew, req.BotID)
} else {
_, err = s.db.ExecContext(ctx,
`UPDATE bots SET shared_secret = $1, last_active = NOW() WHERE bot_id = $2`,
encryptedNew, req.BotID)
}
if err != nil {
log.Printf("failed to update bot %s: %v", req.BotID, err)
writeError(w, http.StatusInternalServerError, "failed to update bot")
return
}
log.Printf("rotated key for bot %s (retire=%v)", req.BotID, req.Retire)
resp := map[string]interface{}{
"bot_id": req.BotID,
"shared_secret": newSecret,
}
if req.Retire {
resp["status"] = "retired"
}
writeJSON(w, http.StatusOK, resp)
}
// handleListBots handles GET /api/bots
// Returns leaderboard snapshot of all active bots.
func (s *Server) handleListBots(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Parse query parameters for pagination
limit := 100
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if n, err := strconv.Atoi(o); err == nil && n >= 0 {
offset = n
}
}
// Query active bots ordered by rating
rows, err := s.db.QueryContext(ctx, `
SELECT b.bot_id, b.name, b.owner, b.rating_mu, b.rating_phi,
b.evolved, b.island, b.generation,
to_char(b.created_at, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as created_at,
COALESCE(wins.wins, 0) as wins, COALESCE(losses.losses, 0) as losses
FROM bots b
LEFT JOIN (
SELECT mp.bot_id, COUNT(*) FILTER (WHERE m.winner = mp.player_slot) as wins
FROM match_participants mp
JOIN matches m ON mp.match_id = m.match_id
WHERE m.status = 'completed'
GROUP BY mp.bot_id
) wins ON b.bot_id = wins.bot_id
LEFT JOIN (
SELECT mp.bot_id, COUNT(*) FILTER (WHERE m.winner IS NOT NULL AND m.winner != mp.player_slot) as losses
FROM match_participants mp
JOIN matches m ON mp.match_id = m.match_id
WHERE m.status = 'completed'
GROUP BY mp.bot_id
) losses ON b.bot_id = losses.bot_id
WHERE b.status = 'active'
ORDER BY (b.rating_mu - 2*b.rating_phi) DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
log.Printf("database error listing bots: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
bots := make([]map[string]interface{}, 0)
for rows.Next() {
var bot struct {
BotID string `json:"bot_id"`
Name string `json:"name"`
Owner string `json:"owner"`
RatingMu float64 `json:"rating_mu"`
RatingPhi float64 `json:"rating_phi"`
Evolved bool `json:"evolved"`
Island *string `json:"island,omitempty"`
Generation *int `json:"generation,omitempty"`
CreatedAt string `json:"created_at"`
Wins int `json:"wins"`
Losses int `json:"losses"`
}
err := rows.Scan(
&bot.BotID, &bot.Name, &bot.Owner, &bot.RatingMu, &bot.RatingPhi,
&bot.Evolved, &bot.Island, &bot.Generation, &bot.CreatedAt,
&bot.Wins, &bot.Losses,
)
if err != nil {
log.Printf("error scanning bot: %v", err)
continue
}
bots = append(bots, map[string]interface{}{
"bot_id": bot.BotID,
"name": bot.Name,
"owner": bot.Owner,
"rating": bot.RatingMu - 2*bot.RatingPhi,
"rating_mu": bot.RatingMu,
"rating_phi": bot.RatingPhi,
"evolved": bot.Evolved,
"island": bot.Island,
"generation": bot.Generation,
"created_at": bot.CreatedAt,
"record": map[string]int{
"wins": bot.Wins,
"losses": bot.Losses,
},
})
}
if rows.Err() != nil {
log.Printf("error iterating bots: %v", rows.Err())
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"bots": bots,
"limit": limit,
"offset": offset,
"count": len(bots),
})
}
// handlePredict handles POST /api/predict
// Accepts {match_id, bot_id, confidence, predictor_id} and writes to predictions table.
// Rejects if the match has already started (status != 'pending').
func (s *Server) handlePredict(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
MatchID string `json:"match_id"`
BotID string `json:"bot_id"`
Predictor string `json:"predictor_id"`
Confidence *int `json:"confidence"` // optional 1-100
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.MatchID == "" || req.BotID == "" || req.Predictor == "" {
writeError(w, http.StatusBadRequest, "match_id, bot_id, and predictor_id are required")
return
}
if req.Confidence != nil && (*req.Confidence < 1 || *req.Confidence > 100) {
writeError(w, http.StatusBadRequest, "confidence must be between 1 and 100")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Verify match exists and hasn't started
var matchStatus string
err := s.db.QueryRowContext(ctx, `
SELECT status FROM matches WHERE match_id = $1
`, req.MatchID).Scan(&matchStatus)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "match not found")
return
} else if err != nil {
log.Printf("database error checking match: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
if matchStatus != "pending" {
writeError(w, http.StatusConflict, "match has already started")
return
}
// Verify bot is a participant in this match
var participantExists bool
err = s.db.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM match_participants
WHERE match_id = $1 AND bot_id = $2
)
`, req.MatchID, req.BotID).Scan(&participantExists)
if err != nil {
log.Printf("database error checking participant: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
if !participantExists {
writeError(w, http.StatusBadRequest, "bot is not a participant in this match")
return
}
// Insert prediction (UNIQUE constraint handles duplicates)
var predictionID int64
err = s.db.QueryRowContext(ctx, `
INSERT INTO predictions (match_id, predictor_id, predicted_bot, confidence)
VALUES ($1, $2, $3, $4)
ON CONFLICT (match_id, predictor_id) DO UPDATE SET predicted_bot = $3, confidence = $4
RETURNING id
`, req.MatchID, req.Predictor, req.BotID, req.Confidence).Scan(&predictionID)
if err != nil {
log.Printf("failed to insert prediction: %v", err)
writeError(w, http.StatusInternalServerError, "failed to submit prediction")
return
}
resp := map[string]interface{}{
"id": predictionID,
"match_id": req.MatchID,
"predicted": req.BotID,
"predictor": req.Predictor,
}
if req.Confidence != nil {
resp["confidence"] = *req.Confidence
}
writeJSON(w, http.StatusCreated, resp)
}
// handleOpenPredictions handles GET /api/predictions/open
// Returns pending matches that are open for predictions, along with
// any predictions the requesting predictor has made.
func (s *Server) handleOpenPredictions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
predictorID := r.URL.Query().Get("predictor_id")
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Get pending matches with their participants
rows, err := s.db.QueryContext(ctx, `
SELECT m.match_id, m.created_at,
COALESCE(json_agg(json_build_object('bot_id', mp.bot_id, 'name', b.name, 'rating', b.rating_mu - 2*b.rating_phi)) FILTER (WHERE mp.bot_id IS NOT NULL), '[]')
FROM matches m
JOIN match_participants mp ON m.match_id = mp.match_id
JOIN bots b ON mp.bot_id = b.bot_id
WHERE m.status = 'pending'
GROUP BY m.match_id, m.created_at
ORDER BY m.created_at ASC
LIMIT 20
`)
if err != nil {
log.Printf("database error fetching open matches: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
type MatchPrediction struct {
MatchID string `json:"match_id"`
CreatedAt string `json:"created_at"`
Participants []map[string]interface{} `json:"participants"`
YourPick *string `json:"your_pick,omitempty"`
}
var matches []MatchPrediction
for rows.Next() {
var m MatchPrediction
var participantsJSON string
if err := rows.Scan(&m.MatchID, &m.CreatedAt, &participantsJSON); err != nil {
log.Printf("error scanning match: %v", err)
continue
}
json.Unmarshal([]byte(participantsJSON), &m.Participants)
// If predictor_id given, look up their existing prediction
if predictorID != "" {
var predictedBot string
err := s.db.QueryRowContext(ctx, `
SELECT predicted_bot FROM predictions
WHERE match_id = $1 AND predictor_id = $2
`, m.MatchID, predictorID).Scan(&predictedBot)
if err == nil {
m.YourPick = &predictedBot
}
}
matches = append(matches, m)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"matches": matches,
})
}
// resolvePredictions marks open predictions as correct/incorrect and updates predictor_stats.
func (s *Server) resolvePredictions(ctx context.Context, tx *sql.Tx, matchID string, winnerBotID string) error {
var rows *sql.Rows
var err error
if winnerBotID == "" {
rows, err = tx.QueryContext(ctx, `
UPDATE predictions
SET correct = false, resolved_at = NOW()
WHERE match_id = $1 AND correct IS NULL
RETURNING predictor_id, correct
`, matchID)
} else {
rows, err = tx.QueryContext(ctx, `
UPDATE predictions
SET correct = (predicted_bot = $1), resolved_at = NOW()
WHERE match_id = $2 AND correct IS NULL
RETURNING predictor_id, correct
`, winnerBotID, matchID)
}
if err != nil {
return fmt.Errorf("failed to resolve predictions: %w", err)
}
defer rows.Close()
for rows.Next() {
var predictorID string
var correct bool
if err := rows.Scan(&predictorID, &correct); err != nil {
return fmt.Errorf("failed to scan resolved prediction: %w", err)
}
if err := s.upsertPredictorStats(ctx, tx, predictorID, correct); err != nil {
return fmt.Errorf("failed to update predictor_stats for %s: %w", predictorID, err)
}
}
return nil
}
// upsertPredictorStats updates the predictor_stats row for a single resolution.
func (s *Server) upsertPredictorStats(ctx context.Context, tx *sql.Tx, predictorID string, correct bool) error {
if correct {
_, err := tx.ExecContext(ctx, `
INSERT INTO predictor_stats (predictor_id, correct, streak, best_streak, updated_at)
VALUES ($1, 1, 1, 1, NOW())
ON CONFLICT (predictor_id) DO UPDATE SET
correct = predictor_stats.correct + 1,
streak = predictor_stats.streak + 1,
best_streak = GREATEST(predictor_stats.best_streak, predictor_stats.streak + 1),
updated_at = NOW()
`, predictorID)
return err
}
_, err := tx.ExecContext(ctx, `
INSERT INTO predictor_stats (predictor_id, incorrect, streak, best_streak, updated_at)
VALUES ($1, 1, 0, 0, NOW())
ON CONFLICT (predictor_id) DO UPDATE SET
incorrect = predictor_stats.incorrect + 1,
streak = 0,
updated_at = NOW()
`, predictorID)
return err
}
// handlePredictionHistory handles GET /api/predictions/history
// Returns resolved predictions for a predictor, used for polling resolution status.
func (s *Server) handlePredictionHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
predictorID := r.URL.Query().Get("predictor_id")
if predictorID == "" {
writeError(w, http.StatusBadRequest, "predictor_id is required")
return
}
limitStr := r.URL.Query().Get("limit")
limit := 20
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := s.db.QueryContext(ctx, `
SELECT p.id, p.match_id, p.predicted_bot,
COALESCE(wb.name, p.predicted_bot) AS predicted_name,
p.correct, p.confidence, p.created_at, p.resolved_at,
m.status AS match_status, m.winner,
COALESCE(CASE WHEN m.winner IS NOT NULL THEN
(SELECT b.name FROM match_participants mp2 JOIN bots b ON mp2.bot_id = b.bot_id
WHERE mp2.match_id = m.match_id AND mp2.player_slot = m.winner)
END, '') AS winner_name
FROM predictions p
JOIN matches m ON p.match_id = m.match_id
LEFT JOIN bots wb ON p.predicted_bot = wb.bot_id
WHERE p.predictor_id = $1
ORDER BY COALESCE(p.resolved_at, p.created_at) DESC
LIMIT $2
`, predictorID, limit)
if err != nil {
log.Printf("database error fetching prediction history: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
type PredictionEntry struct {
ID int64 `json:"id"`
MatchID string `json:"match_id"`
PredictedBot string `json:"predicted_bot"`
PredictedName string `json:"predicted_name"`
Correct *bool `json:"correct"`
Confidence *int `json:"confidence,omitempty"`
CreatedAt string `json:"created_at"`
ResolvedAt *string `json:"resolved_at,omitempty"`
MatchStatus string `json:"match_status"`
WinnerName string `json:"winner_name,omitempty"`
}
var predictions []PredictionEntry
for rows.Next() {
var p PredictionEntry
var createdAt time.Time
var resolvedAt sql.NullTime
var winnerName sql.NullString
var winnerSlot sql.NullInt64
if err := rows.Scan(&p.ID, &p.MatchID, &p.PredictedBot, &p.PredictedName,
&p.Correct, &p.Confidence, &createdAt, &resolvedAt,
&p.MatchStatus, &winnerSlot, &winnerName); err != nil {
log.Printf("error scanning prediction: %v", err)
continue
}
p.CreatedAt = createdAt.Format(time.RFC3339)
if resolvedAt.Valid {
s := resolvedAt.Time.Format(time.RFC3339)
p.ResolvedAt = &s
}
if winnerName.Valid {
p.WinnerName = winnerName.String
}
predictions = append(predictions, p)
}
if predictions == nil {
predictions = []PredictionEntry{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"predictions": predictions,
})
}
// handleUIFeedback handles POST /api/feedback
// Accepts community replay feedback per plan §13.6.
// Stores in replay_feedback table with type enum: insight, mistake, idea, highlight.
func (s *Server) handleUIFeedback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
MatchID string `json:"match_id"`
Turn int `json:"turn"`
Type string `json:"type"` // insight, mistake, idea, highlight
Body string `json:"body"`
Author string `json:"author"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.MatchID == "" || req.Type == "" || req.Body == "" {
writeError(w, http.StatusBadRequest, "match_id, type, and body are required")
return
}
validTypes := map[string]bool{"insight": true, "mistake": true, "idea": true, "highlight": true}
if !validTypes[req.Type] {
writeError(w, http.StatusBadRequest, "type must be one of: insight, mistake, idea, highlight")
return
}
if req.Author == "" {
req.Author = "Anonymous"
}
feedbackID, err := generateID("fb_", 6)
if err != nil {
log.Printf("failed to generate feedback ID: %v", err)
writeError(w, http.StatusInternalServerError, "failed to generate ID")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err = s.db.ExecContext(ctx, `
INSERT INTO replay_feedback (feedback_id, match_id, turn, type, body, author, upvotes, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 0, NOW())
`, feedbackID, req.MatchID, req.Turn, req.Type, req.Body, req.Author)
if err != nil {
log.Printf("[FEEDBACK] db insert failed: match=%s turn=%d type=%s: %v", req.MatchID, req.Turn, req.Type, err)
writeError(w, http.StatusInternalServerError, "failed to store feedback")
return
}
log.Printf("[FEEDBACK] stored: id=%s match=%s turn=%d type=%s", feedbackID, req.MatchID, req.Turn, req.Type)
writeJSON(w, http.StatusCreated, map[string]string{"status": "recorded", "feedback_id": feedbackID})
}
// handleFeedbackUpvote handles POST /api/feedback/{feedback_id}/upvote
// One upvote per visitor (deduped by voter_id from localStorage UUID).
func (s *Server) handleFeedbackUpvote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Extract feedback_id from path: /api/feedback/{feedback_id}/upvote
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/feedback/"), "/")
if len(pathParts) < 2 || pathParts[0] == "" || pathParts[1] != "upvote" {
writeError(w, http.StatusBadRequest, "invalid path")
return
}
feedbackID := pathParts[0]
var req struct {
VoterID string `json:"voter_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.VoterID == "" {
writeError(w, http.StatusBadRequest, "voter_id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Insert upvote (UNIQUE constraint on feedback_id+voter_id prevents duplicates)
var inserted bool
err := s.db.QueryRowContext(ctx, `
INSERT INTO feedback_upvotes (feedback_id, voter_id)
VALUES ($1, $2)
ON CONFLICT (feedback_id, voter_id) DO NOTHING
RETURNING true
`, feedbackID, req.VoterID).Scan(&inserted)
if err == sql.ErrNoRows {
// Already upvoted
writeJSON(w, http.StatusOK, map[string]string{"status": "already_upvoted"})
return
} else if err != nil {
log.Printf("[FEEDBACK] upvote failed: feedback=%s voter=%s: %v", feedbackID, req.VoterID, err)
writeError(w, http.StatusInternalServerError, "failed to upvote")
return
}
// Increment upvote counter on the feedback row
_, err = s.db.ExecContext(ctx, `
UPDATE replay_feedback SET upvotes = upvotes + 1 WHERE feedback_id = $1
`, feedbackID)
if err != nil {
log.Printf("[FEEDBACK] upvote counter increment failed: %v", err)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "upvoted"})
}
// handleGetFeedback handles GET /api/feedback/{match_id}
// Returns all feedback for a match sorted by turn then upvotes descending.
func (s *Server) handleGetFeedback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Extract match_id from path: /api/feedback/{match_id}
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/feedback/"), "/")
if len(pathParts) == 0 || pathParts[0] == "" {
writeError(w, http.StatusBadRequest, "invalid match ID")
return
}
matchID := pathParts[0]
// Don't treat upvote or other sub-paths as match_id lookups
if matchID == "upvote" {
writeError(w, http.StatusBadRequest, "invalid match ID")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := s.db.QueryContext(ctx, `
SELECT feedback_id, match_id, turn, type, body, author, upvotes,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SSZ') as created_at
FROM replay_feedback
WHERE match_id = $1
ORDER BY turn ASC, upvotes DESC, created_at ASC
`, matchID)
if err != nil {
log.Printf("[FEEDBACK] query failed for match %s: %v", matchID, err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
type FeedbackEntry struct {
FeedbackID string `json:"feedback_id"`
MatchID string `json:"match_id"`
Turn int `json:"turn"`
Type string `json:"type"`
Body string `json:"body"`
Author string `json:"author"`
Upvotes int `json:"upvotes"`
CreatedAt string `json:"created_at"`
}
feedback := make([]FeedbackEntry, 0)
for rows.Next() {
var f FeedbackEntry
if err := rows.Scan(&f.FeedbackID, &f.MatchID, &f.Turn, &f.Type, &f.Body, &f.Author, &f.Upvotes, &f.CreatedAt); err != nil {
log.Printf("[FEEDBACK] scan error: %v", err)
continue
}
feedback = append(feedback, f)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"match_id": matchID,
"feedback": feedback,
})
}
// authenticateWorker checks if the request has a valid worker API key
func (s *Server) authenticateWorker(r *http.Request) bool {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return false
}
// Expect "Bearer {api_key}"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return false
}
return parts[1] == s.cfg.WorkerAPIKey
}
func getEnv(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultValue
}