0.1.38 gateway
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user