package api import ( "context" "crypto/subtle" _ "embed" "encoding/json" "net/http" "os" "runtime" "runtime/debug" "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.handleAdminRoot) mux.HandleFunc("GET /admin/{page}", s.handleAdminPage) mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus)) mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations)) mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics)) mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents)) mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime)) 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/request-policy", s.adminAuth(s.handleAdminRequestPolicy)) mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy)) mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy)) mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish)) return mux } var adminPages = map[string]bool{ "library": true, "recommendations": true, "requests": true, "features": true, "playback": true, "maintenance": true, "updates": true, "engagement": true, "imports": true, "logs": true, } func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) { if s.cfg.AdminToken == "" { http.NotFound(w, r) return } http.Redirect(w, r, "/admin/features", http.StatusFound) } type adminRuntimeStatus struct { Goroutines int `json:"goroutines"` GOMAXPROCS int `json:"gomaxprocs"` HeapAlloc uint64 `json:"heapAlloc"` HeapInuse uint64 `json:"heapInuse"` HeapIdle uint64 `json:"heapIdle"` HeapReleased uint64 `json:"heapReleased"` StackInuse uint64 `json:"stackInuse"` Sys uint64 `json:"sys"` NextGC uint64 `json:"nextGc"` NumGC uint32 `json:"numGc"` MemoryLimit int64 `json:"memoryLimit"` ConfiguredLim string `json:"configuredLimit,omitempty"` } func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) { var memory runtime.MemStats runtime.ReadMemStats(&memory) w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, adminRuntimeStatus{ Goroutines: runtime.NumGoroutine(), GOMAXPROCS: runtime.GOMAXPROCS(0), HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse, HeapIdle: memory.HeapIdle, HeapReleased: memory.HeapReleased, StackInuse: memory.StackInuse, Sys: memory.Sys, NextGC: memory.NextGC, NumGC: memory.NumGC, MemoryLimit: debug.SetMemoryLimit(-1), ConfiguredLim: os.Getenv("GOMEMLIMIT"), }) } 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 need both the // admin cookie and a current Emby-verified browser session. Automation can continue to // send the admin token as a Bearer header without pretending to be a browser. 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 } authorization := strings.TrimSpace(r.Header.Get("Authorization")) presented := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) browser := presented == "" if browser { if cookie, err := r.Cookie(adminCookieName); err == nil { presented = cookie.Value } } if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 || (browser && !s.validInstallerSession(r)) { 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 } page := strings.TrimSpace(r.PathValue("page")) if !adminPages[page] { http.NotFound(w, r) return } if !s.validInstallerSession(r) { s.renderAccessLogin(w, r, "", http.StatusOK, "/admin/"+page) 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") preventDiscovery(w) _, _ = 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"` RequestPolicy store.RequestPolicy `json:"requestPolicy"` PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"` Features featureResponse `json:"features"` RequestUsers []store.KnownUser `json:"requestUsers"` Clients []store.KnownClient `json:"clients"` SonarrReady bool `json:"sonarrReady"` RadarrReady bool `json:"radarrReady"` } 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() } requestPolicy, err := s.store.RequestPolicy(ctx) if err != nil { s.log.Warn("request policy read failed", "error", err) } requestUsers, err := s.store.KnownUsers(ctx) if err != nil { s.log.Warn("known users read failed", "error", err) } clients, err := s.store.KnownClients(ctx) if err != nil { s.log.Warn("known clients read failed", "error", err) } 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, RequestPolicy: requestPolicy, PlaybackPolicy: func() store.PlaybackPolicy { policy, policyErr := s.store.PlaybackPolicy(ctx) if policyErr != nil { s.log.Warn("playback policy read failed", "error", policyErr) return store.DefaultPlaybackPolicy() } return policy }(), Features: featurePayload(s.currentFeaturePolicy(ctx), membyProtocolVersion), RequestUsers: requestUsers, Clients: clients, SonarrReady: s.sonarr != nil, RadarrReady: s.radarr != nil, }) } type playbackPolicyRequest struct { PrerollEnabled bool `json:"prerollEnabled"` PrerollDurationMs int64 `json:"prerollDurationMs"` } func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Request) { var req playbackPolicyRequest 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.PrerollDurationMs < 1_000 || req.PrerollDurationMs > 30_000 { writeError(w, http.StatusBadRequest, "preroll duration must be between 1 and 30 seconds") return } policy := store.PlaybackPolicy{ PrerollEnabled: req.PrerollEnabled, PrerollDurationMs: req.PrerollDurationMs, } if err := s.store.SetPlaybackPolicy(r.Context(), policy); err != nil { s.log.Error("playback policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save playback policy") return } stored, err := s.store.PlaybackPolicy(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not reload playback policy") return } s.log.Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled, "preroll_duration_ms", stored.PrerollDurationMs) writeJSON(w, http.StatusOK, stored) } type requestPolicyRequest struct { AllowedUserIDs []string `json:"allowedUserIds"` } func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request) { var req requestPolicyRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } known, err := s.store.KnownUsers(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not validate users") return } valid := make(map[string]bool, len(known)) for _, user := range known { valid[user.ID] = true } seen := map[string]bool{} allowed := make([]string, 0, len(req.AllowedUserIDs)) for _, id := range req.AllowedUserIDs { id = strings.TrimSpace(id) if id == "" || seen[id] { continue } if !valid[id] { writeError(w, http.StatusBadRequest, "unknown Emby user") return } seen[id] = true allowed = append(allowed, id) } policy := store.RequestPolicy{AllowedUserIDs: allowed} if err := s.store.SetRequestPolicy(r.Context(), policy); err != nil { s.log.Error("request policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save request access") return } s.log.Info("media request access changed", "users", len(allowed)) writeJSON(w, http.StatusOK, policy) } 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), } current := s.updatePolicy.get() if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL { // Changing "required" or release notes must not silently discard integrity // metadata added by the signed release publisher. policy.SHA256 = current.SHA256 policy.SizeBytes = current.SizeBytes } 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) }