diff --git a/web/admin.go b/web/admin.go new file mode 100644 index 0000000..d5063bc --- /dev/null +++ b/web/admin.go @@ -0,0 +1,55 @@ +package web + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/tolelom/catacombs/config" + "github.com/tolelom/catacombs/game" + "github.com/tolelom/catacombs/store" +) + +// AdminStats is the JSON response for the /admin endpoint. +type AdminStats struct { + OnlinePlayers int `json:"online_players"` + ActiveRooms int `json:"active_rooms"` + TodayRuns int `json:"today_runs"` + AvgFloorReach float64 `json:"avg_floor_reached"` + UptimeSeconds int64 `json:"uptime_seconds"` +} + +// AdminHandler returns an http.Handler for the /admin stats endpoint. +// It requires Basic Auth using credentials from config. +func AdminHandler(lobby *game.Lobby, db *store.DB, startTime time.Time) http.Handler { + cfg := lobby.Cfg() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !checkAuth(r, cfg.Admin) { + w.Header().Set("WWW-Authenticate", `Basic realm="Catacombs Admin"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + todayRuns, _ := db.GetTodayRunCount() + avgFloor, _ := db.GetTodayAvgFloor() + + stats := AdminStats{ + OnlinePlayers: len(lobby.ListOnline()), + ActiveRooms: len(lobby.ListRooms()), + TodayRuns: todayRuns, + AvgFloorReach: avgFloor, + UptimeSeconds: int64(time.Since(startTime).Seconds()), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) + }) +} + +func checkAuth(r *http.Request, cfg config.AdminConfig) bool { + username, password, ok := r.BasicAuth() + if !ok { + return false + } + return username == cfg.Username && password == cfg.Password +} diff --git a/web/admin_test.go b/web/admin_test.go new file mode 100644 index 0000000..2e797e5 --- /dev/null +++ b/web/admin_test.go @@ -0,0 +1,64 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/tolelom/catacombs/config" + "github.com/tolelom/catacombs/game" + "github.com/tolelom/catacombs/store" +) + +func TestAdminEndpoint(t *testing.T) { + tmpDir := t.TempDir() + db, err := store.Open(filepath.Join(tmpDir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + cfg, _ := config.Load("") + cfg.Admin = config.AdminConfig{Username: "admin", Password: "secret"} + lobby := game.NewLobby(cfg) + + handler := AdminHandler(lobby, db, time.Now()) + + // Test without auth → 401 + req := httptest.NewRequest("GET", "/admin", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + + // Test with wrong auth → 401 + req = httptest.NewRequest("GET", "/admin", nil) + req.SetBasicAuth("admin", "wrong") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + + // Test with correct auth → 200 + JSON + req = httptest.NewRequest("GET", "/admin", nil) + req.SetBasicAuth("admin", "secret") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var stats AdminStats + if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if stats.OnlinePlayers != 0 { + t.Fatalf("expected 0 online, got %d", stats.OnlinePlayers) + } +}