package api import ( "context" "crypto/subtle" _ "embed" "encoding/json" "net/http" "strings" "time" "github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/store" ) //go:embed admin.html var adminPage []byte // adminRoutes is the operator interface: library imports, the maintenance switch, and // row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be // left exposed by accident. func (s *Server) adminRoutes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /admin/{$}", s.handleAdminPage) mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus)) mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) return mux } // adminAuth guards the admin API with a shared token, compared in constant time. func (s *Server) adminAuth(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.cfg.AdminToken == "" { http.NotFound(w, r) return } presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 { writeError(w, http.StatusUnauthorized, "invalid admin token") return } h(w, r) }) } func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) { if s.cfg.AdminToken == "" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") // The page holds no secrets; the token is entered by the operator and kept in the // browser's local storage. w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(adminPage) } type adminStatus struct { Maintenance store.Maintenance `json:"maintenance"` Library store.LibraryStats `json:"library"` SyncRunning bool `json:"syncRunning"` Runs []store.SyncRun `json:"runs"` SyncEvery string `json:"syncEvery"` } func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { ctx := r.Context() stats, err := s.store.LibraryStats(ctx) if err != nil { s.log.Error("library stats failed", "error", err) writeError(w, http.StatusInternalServerError, "could not read library stats") return } runs, err := s.store.RecentSyncRuns(ctx, 10) if err != nil { s.log.Error("sync history failed", "error", err) writeError(w, http.StatusInternalServerError, "could not read sync history") return } writeJSON(w, http.StatusOK, adminStatus{ Maintenance: s.maintenance.get(), Library: stats, SyncRunning: s.syncer.Running(), Runs: runs, SyncEvery: s.cfg.SyncInterval.String(), }) } type syncRequest struct { Kind string `json:"kind"` } // handleAdminSync starts an import in the background and returns immediately. A full // import of a large library takes minutes; the page polls /admin/api/status for progress. func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) { var req syncRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if req.Kind != "full" && req.Kind != "incremental" { writeError(w, http.StatusBadRequest, `kind must be "full" or "incremental"`) return } if s.syncer.Running() { writeError(w, http.StatusConflict, "a sync is already running") return } go func() { ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout) defer cancel() if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil { s.log.Error("manual sync failed", "kind", req.Kind, "error", err) } }() writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind}) } type maintenanceRequest struct { Enabled bool `json:"enabled"` Message string `json:"message"` } func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request) { var req maintenanceRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)} if err := s.store.SetMaintenance(r.Context(), state); err != nil { s.log.Error("maintenance write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not update maintenance mode") return } if err := s.LoadMaintenance(r.Context()); err != nil { s.log.Warn("maintenance reload failed", "error", err) } s.log.Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message) writeJSON(w, http.StatusOK, s.maintenance.get()) } func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) { days := queryInt(r, "days", 7, 90) since := time.Now().UTC().AddDate(0, 0, -days) stats, err := s.store.RowStats(r.Context(), since) if err != nil { s.log.Error("row stats failed", "error", err) writeError(w, http.StatusInternalServerError, "could not read analytics") return } writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats}) } // syncerHandle is the slice of the syncer the API needs, so api does not depend on the // concrete type for testing. type syncerHandle interface { Running() bool Sync(ctx context.Context, kind, trigger string) (library.Result, error) }