Publish current app and server
This commit is contained in:
+196
-15
@@ -6,6 +6,9 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,19 +29,68 @@ const adminCookieName = "memby_admin"
|
||||
func (s *Server) adminRoutes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
|
||||
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{
|
||||
@@ -52,22 +104,25 @@ func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.events.Events(after, limit))
|
||||
}
|
||||
|
||||
// adminAuth guards the admin API with the shared token. Browser requests use the
|
||||
// persistent HttpOnly cookie established by the admin page; automation can continue to
|
||||
// send the token as a Bearer header.
|
||||
// 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
|
||||
}
|
||||
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if presented == "" {
|
||||
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 {
|
||||
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 ||
|
||||
(browser && !s.validInstallerSession(r)) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid admin token")
|
||||
return
|
||||
}
|
||||
@@ -80,6 +135,15 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
@@ -92,18 +156,26 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
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"`
|
||||
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) {
|
||||
@@ -131,6 +203,18 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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(),
|
||||
@@ -140,9 +224,99 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
@@ -171,6 +345,13 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user