package api import ( "context" "crypto/subtle" _ "embed" "encoding/json" "net/http" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/appupdate" "github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/store" ) //go:embed admin.html var adminPage []byte const adminCookieName = "memby_admin" // 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("GET /admin/api/events", s.adminAuth(s.handleAdminEvents)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou)) mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy)) mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish)) return mux } func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) { if s.events == nil { writeJSON(w, http.StatusOK, map[string]any{ "events": []any{}, "next": 0, "oldest": 0, "latest": 0, "dropped": 0, "hasMore": false, }) return } after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) limit := queryInt(r, "limit", 500, 1000) writeJSON(w, http.StatusOK, s.events.Events(after, limit)) } // adminAuth guards the admin API with the shared token. Browser requests use the // persistent HttpOnly cookie established by the admin page; automation can continue to // send the token as a Bearer header. 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 presented == "" { if cookie, err := r.Cookie(adminCookieName); err == nil { presented = cookie.Value } } 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 } secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") http.SetCookie(w, &http.Cookie{ Name: adminCookieName, Value: s.cfg.AdminToken, Path: "/admin", MaxAge: 10 * 365 * 24 * 60 * 60, HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, }) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(adminPage) } type adminStatus struct { Maintenance store.Maintenance `json:"maintenance"` UpdatePolicy appupdate.Policy `json:"updatePolicy"` Library store.LibraryStats `json:"library"` SyncRunning bool `json:"syncRunning"` Runs []store.SyncRun `json:"runs"` SyncEvery string `json:"syncEvery"` ForYou store.ForYouStats `json:"forYou"` ForYouRunning bool `json:"forYouRunning"` } 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 } var forYouStats store.ForYouStats forYouRunning := false if s.forYou != nil { forYouStats, err = s.forYou.Stats(ctx) if err != nil { s.log.Warn("For You stats failed", "error", err) } forYouRunning = s.forYou.Running() } writeJSON(w, http.StatusOK, adminStatus{ Maintenance: s.maintenance.get(), UpdatePolicy: s.updatePolicy.get(), Library: stats, SyncRunning: s.syncer.Running(), Runs: runs, SyncEvery: s.cfg.SyncInterval.String(), ForYou: forYouStats, ForYouRunning: forYouRunning, }) } type updatePolicyRequest struct { Enabled bool `json:"enabled"` LatestVersion string `json:"latestVersion"` DownloadURL string `json:"downloadUrl"` Notes string `json:"notes"` // Required makes this release mandatory for everyone below it. The page offers a // toggle rather than exposing "minimum version" directly, because "force this // update" is the decision an operator actually wants to make. Required bool `json:"required"` // MinimumVersion is honoured when set explicitly, for staged rollouts where the // forced floor is older than the latest build. MinimumVersion string `json:"minimumVersion"` } func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) { var req updatePolicyRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } policy := appupdate.Policy{ Enabled: req.Enabled, LatestVersion: strings.TrimSpace(req.LatestVersion), MinimumVersion: strings.TrimSpace(req.MinimumVersion), DownloadURL: strings.TrimSpace(req.DownloadURL), Notes: strings.TrimSpace(req.Notes), } if req.Required { // Forcing means "nobody below the current build", so the floor is the latest. policy.MinimumVersion = policy.LatestVersion } else if policy.MinimumVersion == policy.LatestVersion { // Un-ticking the box must actually release the floor. policy.MinimumVersion = "" } if policy.Enabled && policy.LatestVersion == "" { writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts") return } if policy.Enabled && policy.DownloadURL == "" { writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts") return } if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil { s.log.Error("update policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save the update policy") return } if err := s.LoadUpdatePolicy(r.Context()); err != nil { s.log.Warn("update policy reload failed", "error", err) } s.log.Info("update policy changed", "enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion) writeJSON(w, http.StatusOK, s.updatePolicy.get()) } 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 forYouAdminRequest struct { Action string `json:"action"` } // handleAdminForYou provides the recovery controls needed for an idempotent backfill: // import all Tracearr sessions again, or rebuild every active user's derived pool. func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) { if s.forYou == nil { writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured") return } var req forYouAdminRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } switch req.Action { case "incremental-import", "full-import", "rebuild-all": default: writeError(w, http.StatusBadRequest, `action must be "incremental-import", "full-import", or "rebuild-all"`) return } if s.forYou.Running() { writeError(w, http.StatusConflict, "For You maintenance is already running") return } go func() { ctx, cancel := context.WithTimeout(context.Background(), s.cfg.SyncTimeout) defer cancel() if req.Action != "rebuild-all" { _, err := s.forYou.Import(ctx, req.Action == "full-import") if err != nil { s.log.Error("manual Tracearr import failed", "action", req.Action, "error", err) return } } if err := s.forYou.RebuildAll(ctx, true); err != nil { s.log.Error("manual For You rebuild failed", "action", req.Action, "error", err) } }() writeJSON(w, http.StatusAccepted, map[string]string{ "status": "started", "action": req.Action, }) } 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) }