Files
memby/server/internal/api/admin.go
T

509 lines
18 KiB
Go
Raw Normal View History

package api
import (
"context"
"crypto/subtle"
_ "embed"
"encoding/json"
"net/http"
2026-08-02 22:10:19 +12:00
"os"
"runtime"
"runtime/debug"
2026-07-29 15:26:27 +12:00
"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
2026-07-29 15:26:27 +12:00
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()
2026-08-02 22:10:19 +12:00
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
2026-08-02 22:10:19 +12:00
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
2026-07-29 15:26:27 +12:00
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
2026-08-02 22:10:19 +12:00
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
2026-07-29 15:26:27 +12:00
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))
2026-08-02 22:10:19 +12:00
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))
2026-07-27 21:06:51 +12:00
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
return mux
}
2026-08-02 22:10:19 +12:00
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"),
})
}
2026-07-29 15:26:27 +12:00
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))
}
2026-08-02 22:10:19 +12:00
// 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
}
2026-08-02 22:10:19 +12:00
authorization := strings.TrimSpace(r.Header.Get("Authorization"))
presented := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
browser := presented == ""
if browser {
2026-07-29 15:26:27 +12:00
if cookie, err := r.Cookie(adminCookieName); err == nil {
presented = cookie.Value
}
}
2026-08-02 22:10:19 +12:00
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
}
2026-08-02 22:10:19 +12:00
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
}
2026-07-29 15:26:27 +12:00
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")
2026-08-02 22:10:19 +12:00
preventDiscovery(w)
_, _ = w.Write(adminPage)
}
type adminStatus struct {
2026-08-02 22:10:19 +12:00
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
}
2026-07-29 15:26:27 +12:00
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()
}
2026-08-02 22:10:19 +12:00
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{
2026-07-29 15:26:27 +12:00
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,
2026-08-02 22:10:19 +12:00
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,
})
}
2026-08-02 22:10:19 +12:00
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),
}
2026-08-02 22:10:19 +12:00
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})
}
2026-07-29 15:26:27 +12:00
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)
}