0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+60 -82
View File
@@ -32,16 +32,7 @@ const adminActivityHeader = "X-Memby-Admin-Active"
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))
@@ -58,6 +49,7 @@ func (s *Server) adminRoutes() http.Handler {
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/views", s.adminAuth(s.handleAdminViews))
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))
@@ -76,15 +68,50 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
return mux
}
// Sign-in history. Three routes rather than one because they answer three questions
// an operator asks separately — what happened, which televisions are connecting, and
// everything about this one set.
mux.Handle("GET /admin/api/logins", s.adminAuth(s.handleAdminLogins))
mux.Handle("GET /admin/api/logins/devices", s.adminAuth(s.handleAdminLoginDevices))
mux.Handle("GET /admin/api/logins/devices/{deviceID}", s.adminAuth(s.handleAdminDeviceDetail))
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)
// The administrative feed behind the notification bell.
mux.Handle("GET /admin/api/notifications", s.adminAuth(s.handleAdminNotifications))
mux.Handle("POST /admin/api/notifications/read", s.adminAuth(s.handleAdminNotificationsRead))
// The live half. Registered outside adminAuth's activity tracking is deliberate — see
// operatorPresent: a stream held open for an hour must not, on its own, keep an
// abandoned tab's session alive.
mux.Handle("GET /admin/api/notifications/stream", s.adminAuth(s.handleAdminNotificationStream))
mux.Handle("GET /admin/api/integrations", s.adminAuth(s.handleAdminIntegrations))
mux.Handle("POST /admin/api/integrations", s.adminAuth(s.handleAdminSaveIntegration))
mux.Handle("DELETE /admin/api/integrations/{integrationID}", s.adminAuth(s.handleAdminDeleteIntegration))
mux.Handle("POST /admin/api/integrations/{integrationID}/test", s.adminAuth(s.handleAdminTestIntegration))
mux.Handle("GET /admin/api/tasks", s.adminAuth(s.handleAdminTasks))
mux.Handle("POST /admin/api/tasks/{taskID}/run", s.adminAuth(s.handleAdminRunTask))
mux.Handle("PUT /admin/api/tasks/{taskID}", s.adminAuth(s.handleAdminTaskSettings))
// An unmatched API path is a 404, stated rather than left to the catch-all below —
// otherwise a mistyped or removed route would answer with the console's HTML shell,
// and the caller would report "unexpected token < in JSON" instead of "no such route".
mux.HandleFunc("/admin/api/", func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
writeError(w, http.StatusNotFound, "no such admin route")
})
// Everything else under /admin is the console, which owns its own URLs: a deep link, a
// refresh or the Back button all arrive here as a GET for a path this server has never
// heard of. This intentionally has no method qualifier. `GET /admin/` and the all-method
// `/admin/api/` fallback overlap in Go's ServeMux without either pattern being more
// specific, which makes the gateway panic while registering routes. The API fallback is
// more specific by path, so it continues to win here for every method.
mux.HandleFunc("/admin/", s.handleAdminConsole)
return mux
}
type adminRuntimeStatus struct {
@@ -169,71 +196,6 @@ func operatorPresent(r *http.Request) bool {
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)
@@ -271,6 +233,7 @@ type adminStatus struct {
Subtitles subtitleAdminSettings `json:"subtitles"`
Features featureResponse `json:"features"`
RequestUsers []store.KnownUser `json:"requestUsers"`
RequestUsage []store.RequestUsage `json:"requestUsage"`
Clients []store.KnownClient `json:"clients"`
SonarrReady bool `json:"sonarrReady"`
RadarrReady bool `json:"radarrReady"`
@@ -313,6 +276,10 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
if err != nil {
s.loggerFor(ctx).Warn("known clients read failed", "error", err)
}
requestUsage, err := s.store.RequestUsage(ctx)
if err != nil {
s.loggerFor(ctx).Warn("request usage read failed", "error", err)
}
writeJSON(w, http.StatusOK, adminStatus{
ServerVersion: buildinfo.Version(),
Maintenance: s.maintenance.get(),
@@ -336,6 +303,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
Subtitles: s.subtitleAdminSettings(ctx),
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
RequestUsers: requestUsers,
RequestUsage: requestUsage,
Clients: clients,
MDBList: func() mdblistAdminSettings {
settings, settingsErr := s.store.MDBListSettings(ctx)
@@ -808,6 +776,16 @@ func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, payload)
}
func (s *Server) handleAdminViews(w http.ResponseWriter, r *http.Request) {
report, err := s.store.ViewsReport(r.Context(), time.Now().UTC())
if err != nil {
s.loggerFor(r.Context()).Error("views report failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read app views")
return
}
writeJSON(w, http.StatusOK, report)
}
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
// concrete type for testing.
type syncerHandle interface {