package api import ( "context" "crypto/subtle" _ "embed" "encoding/json" "net/http" "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 // 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)) mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy)) mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish)) 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"` UpdatePolicy appupdate.Policy `json:"updatePolicy"` 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(), UpdatePolicy: s.updatePolicy.get(), Library: stats, SyncRunning: s.syncer.Running(), Runs: runs, SyncEvery: s.cfg.SyncInterval.String(), }) } 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 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) }