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/buildinfo" "github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/store" ) const adminCookieName = "memby_admin" // adminActivityHeader is how the console distinguishes its own poll from a request an // operator caused. It is advice, not authority: a page that sets it always already holds // a valid session, so the only thing it can extend is its own sign-in. const adminActivityHeader = "X-Memby-Admin-Active" // adminRoutes is the operator interface: library imports, maintenance and analytics. // 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("POST /admin/logout", s.handleAdminLogout) mux.HandleFunc("GET /admin/{page}", s.handleAdminPage) // One person's own page. It is a path rather than a query string so it can be linked, // bookmarked and returned to after a sign-in, like every other page here. mux.HandleFunc("GET /admin/accounts/{userID}", s.handleAdminAccountPage) // The settings history is its own page rather than a fifth card on the account: it is // a table with a row per change and an action per row, and it is read when something // has gone wrong rather than as part of ordinary account admin. mux.HandleFunc("GET /admin/accounts/{userID}/settings", s.handleAdminSettingsHistoryPage) mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus)) mux.Handle("GET /admin/api/accounts", s.adminAuth(s.handleAdminAccounts)) mux.Handle("PUT /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminRenameDevice)) mux.Handle("DELETE /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminDeleteDevice)) mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount)) mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences)) mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences)) mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory)) mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore", s.adminAuth(s.handleAdminRestorePreferences)) mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations)) mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations)) mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes)) 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/journeys", s.adminAuth(s.handleAdminJourneys)) mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches)) 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/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert)) 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/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings)) mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings)) mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest)) mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy)) mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish)) return mux } func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) { if s.cfg.AdminToken == "" { http.NotFound(w, r) return } http.Redirect(w, r, "/admin/overview", 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 } if browser && operatorPresent(r) { s.renewAdminSession(w, r) } h(w, r) }) } // operatorPresent reports whether a request came from somebody at the keyboard rather // than from the console's own status poll. Only these extend the sign-in: the dashboard // refreshes itself on a timer, so renewing on any request at all would leave a tab // abandoned on a second monitor signed in forever. A mutation is a click by definition; // for reads the page says so itself, setting the header only while there has been recent // interaction with it. func operatorPresent(r *http.Request) bool { return r.Method != http.MethodGet || strings.TrimSpace(r.Header.Get(adminActivityHeader)) == "1" } func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) { // A hidden page is reachable only through the route that knows how to address it: an // account page with nobody to be about is not a page. page := strings.TrimSpace(r.PathValue("page")) if !adminPages[page] { http.NotFound(w, r) return } s.serveAdminPage(w, r, page, "/admin/"+page) } // The account page is served from its own route because the identity is in the path. The // sign-in returns to /admin/accounts rather than to this URL: cleanInstallerDestination only // admits the pages it can name, and a person's id is not one of them. func (s *Server) handleAdminAccountPage(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(r.PathValue("userID")) == "" { http.NotFound(w, r) return } s.serveAdminPage(w, r, "account", "/admin/accounts") } // Hidden for the same reason the account page is: a settings history with nobody to be // about is not a page, so the identity is in the path and a sign-in returns to the account // list rather than here. func (s *Server) handleAdminSettingsHistoryPage(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(r.PathValue("userID")) == "" { http.NotFound(w, r) return } s.serveAdminPage(w, r, "settings-history", "/admin/accounts") } func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, next string) { if s.cfg.AdminToken == "" { http.NotFound(w, r) return } body, ok := adminRendered[page] if !ok { http.NotFound(w, r) return } if !s.validInstallerSession(r) { s.renderAccessLogin(w, r, "", http.StatusOK, next) return } // Opening a page is somebody at the keyboard, so it starts the clock again. s.renewAdminSession(w, r) 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(body) } func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) { if s.cfg.AdminToken == "" { http.NotFound(w, r) return } s.clearInstallerCookie(w) secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") http.SetCookie(w, &http.Cookie{ Name: adminCookieName, Path: "/admin", MaxAge: -1, HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, }) http.Redirect(w, r, "/admin/", http.StatusSeeOther) } type adminStatus struct { // ServerVersion is what the page's footer reports. An operator reading the live log // needs to know which build wrote it, and the page is the one place that is asked. ServerVersion string `json:"serverVersion"` 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"` MDBList mdblistAdminSettings `json:"mdblist"` Subtitles subtitleAdminSettings `json:"subtitles"` 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.loggerFor(ctx).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.loggerFor(ctx).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.loggerFor(ctx).Warn("For You stats failed", "error", err) } forYouRunning = s.forYou.Running() } requestPolicy, err := s.store.RequestPolicy(ctx) if err != nil { s.loggerFor(ctx).Warn("request policy read failed", "error", err) } requestUsers, err := s.store.KnownUsers(ctx) if err != nil { s.loggerFor(ctx).Warn("known users read failed", "error", err) } clients, err := s.store.KnownClients(ctx) if err != nil { s.loggerFor(ctx).Warn("known clients read failed", "error", err) } writeJSON(w, http.StatusOK, adminStatus{ ServerVersion: buildinfo.Version(), 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.loggerFor(ctx).Warn("playback policy read failed", "error", policyErr) return store.DefaultPlaybackPolicy() } return policy }(), Subtitles: s.subtitleAdminSettings(ctx), Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion), RequestUsers: requestUsers, Clients: clients, MDBList: func() mdblistAdminSettings { settings, settingsErr := s.store.MDBListSettings(ctx) if settingsErr != nil { s.loggerFor(ctx).Warn("MDBList settings read failed", "error", settingsErr) settings = store.DefaultMDBListSettings() } public := publicMDBListSettings(settings) // How much of the catalogue is already stored is the only way to see whether // the integration is still spending the operator's daily allowance. if total, stale, statsErr := s.store.MediaRatingsStats( ctx, time.Now().Add(-ratingsRefreshInterval), ); statsErr == nil { public.CachedTitles, public.StaleTitles = total, stale } return public }(), SonarrReady: s.sonarr != nil, RadarrReady: s.radarr != nil, }) } type mdblistAdminSettings struct { Enabled bool `json:"enabled"` APIKeyConfigured bool `json:"apiKeyConfigured"` Sources []string `json:"sources"` AvailableSources []string `json:"availableSources"` CachedTitles int `json:"cachedTitles"` StaleTitles int `json:"staleTitles"` } type mdblistSettingsRequest struct { Enabled bool `json:"enabled"` APIKey string `json:"apiKey"` ClearAPIKey bool `json:"clearApiKey"` Sources []string `json:"sources"` } func publicMDBListSettings(settings store.MDBListSettings) mdblistAdminSettings { return mdblistAdminSettings{ Enabled: settings.Enabled, APIKeyConfigured: settings.APIKey != "", Sources: settings.Sources, AvailableSources: store.MDBListSources(), } } func (s *Server) handleAdminMDBListSettings(w http.ResponseWriter, r *http.Request) { var req mdblistSettingsRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } current, err := s.store.MDBListSettings(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not read MDBList settings") return } apiKey := current.APIKey if req.ClearAPIKey { apiKey = "" } else if replacement := strings.TrimSpace(req.APIKey); replacement != "" { apiKey = replacement } seen := map[string]bool{} sources := make([]string, 0, len(req.Sources)) for _, source := range req.Sources { source = strings.ToLower(strings.TrimSpace(source)) if source == "" || seen[source] { continue } if !store.ValidMDBListSource(source) { writeError(w, http.StatusBadRequest, "unknown MDBList rating source") return } seen[source] = true sources = append(sources, source) } if req.Enabled && apiKey == "" { writeError(w, http.StatusBadRequest, "set an MDBList API key before enabling ratings") return } if req.Enabled && len(sources) == 0 { writeError(w, http.StatusBadRequest, "select at least one MDBList rating source") return } if len(sources) == 0 { sources = current.Sources } next := store.MDBListSettings{Enabled: req.Enabled, APIKey: apiKey, Sources: sources} if err := s.store.SetMDBListSettings(r.Context(), next); err != nil { s.loggerFor(r.Context()).Error("MDBList settings write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save MDBList settings") return } s.forgetMDBListSettings() stored, err := s.store.MDBListSettings(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not reload MDBList settings") return } s.loggerFor(r.Context()).Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources), "api_key_configured", stored.APIKey != "") writeJSON(w, http.StatusOK, publicMDBListSettings(stored)) } 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.loggerFor(r.Context()).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.loggerFor(r.Context()).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.loggerFor(r.Context()).Error("request policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save request access") return } s.loggerFor(r.Context()).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.loggerFor(r.Context()).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.loggerFor(r.Context()).Warn("update policy reload failed", "error", err) } s.loggerFor(r.Context()).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 { // Detached from the request on purpose, so the identity in the request // context is gone by now; name the area explicitly instead. s.log.Error("manual sync failed", "component", "admin", "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", "component", "admin", "action", req.Action, "error", err) return } } if err := s.forYou.RebuildAll(ctx, true); err != nil { s.log.Error("manual For You rebuild failed", "component", "admin", "action", req.Action, "error", err) } }() writeJSON(w, http.StatusAccepted, map[string]string{ "status": "started", "action": req.Action, }) } // handleAdminDeploymentAlert announces that this gateway is about to be replaced. // // It takes no body and says the same thing every time: the caller is `deploy-server.ps1`, // which runs before the old container is stopped, and a deployment script has no business // deciding what appears over somebody's film. It is deliberately outside the maintenance // switch — a deployment is not maintenance mode, and the point is to say so while the app // is still working. func (s *Server) handleAdminDeploymentAlert(w http.ResponseWriter, r *http.Request) { if s.cache == nil { // Alerts live in Redis; with none there is nowhere to publish to, and the deploy // must be told rather than believing every TV was warned. writeError(w, http.StatusServiceUnavailable, "alerts are unavailable without Redis") return } s.AnnounceDeployment(r.Context()) s.loggerFor(r.Context()).Warn("deployment announced to clients", "component", "admin", "window", deploymentAlertWindow.String()) writeJSON(w, http.StatusAccepted, map[string]string{ "status": "announced", "title": deploymentAlertTitle, "message": deploymentAlertMessage, }) } 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.loggerFor(r.Context()).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.loggerFor(r.Context()).Warn("maintenance reload failed", "error", err) } s.loggerFor(r.Context()).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.loggerFor(r.Context()).Error("row stats failed", "error", err) writeError(w, http.StatusInternalServerError, "could not read analytics") return } // Keep the journey fields on this older endpoint for scripts written before journeys // gained their own page and endpoint. The console itself only reads rows from here. users, err := s.store.AnalyticsUsers(r.Context(), since) if err != nil { s.loggerFor(r.Context()).Error("user analytics failed", "error", err) writeError(w, http.StatusInternalServerError, "could not read analytics") return } payload := map[string]any{"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)), "rows": stats, "users": users} userID := strings.TrimSpace(r.URL.Query().Get("userId")) if userID != "" { features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since) paths, pathErr := s.store.UserPaths(r.Context(), userID, since) events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000) if featureErr != nil || pathErr != nil || eventErr != nil { s.loggerFor(r.Context()).Error("legacy user journey read failed", "user_id", userID) writeError(w, http.StatusInternalServerError, "could not read user journey") return } payload["userId"] = userID payload["features"] = features payload["paths"] = paths payload["events"] = events } writeJSON(w, http.StatusOK, payload) } func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) { days := queryInt(r, "days", 30, 90) since := time.Now().UTC().AddDate(0, 0, -days) userID := strings.TrimSpace(r.URL.Query().Get("userId")) users, err := s.store.AnalyticsUsers(r.Context(), since) if err != nil { s.loggerFor(r.Context()).Error("journey users failed", "error", err) writeError(w, http.StatusInternalServerError, "could not read journeys") return } stats, statsErr := s.store.JourneyStats(r.Context(), userID, since) features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since) paths, pathErr := s.store.UserPaths(r.Context(), userID, since) actions, actionErr := s.store.JourneyActionStats(r.Context(), userID, since) if statsErr != nil || featureErr != nil || pathErr != nil || actionErr != nil { s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID) writeError(w, http.StatusInternalServerError, "could not read journeys") return } payload := map[string]any{ "days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)), "users": users, "stats": stats, "features": features, "paths": paths, "actions": actions, } if userID != "" { events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000) if eventErr != nil { s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID) writeError(w, http.StatusInternalServerError, "could not read user journey") return } payload["userId"] = userID payload["events"] = events } writeJSON(w, http.StatusOK, payload) } // 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) }