Files
memby/server/internal/api/admin_spa.go
ponzischeme89andClaude Sonnet 5 0b1d329f7c Admin console: show the signed-in operator's name, not "Administrator"
The user switcher fell back to "Administrator" whenever /admin/api/status
returned no currentUser. Fresh sign-ins already embed the verified Emby
account name in the session cookie, but a session minted before that field
existed (or one that has not been re-issued since) is valid yet anonymous,
so the fallback showed for ever.

handleAdminConsole now treats a valid-but-anonymous admin session as needing
a fresh sign-in for the SPA shell only — a one-time prompt that fills the
name in, since session renewal preserves whatever the cookie already held.
Asset requests are unaffected, so nothing breaks mid-session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wS9Qz3Fkxeu9KPt26hhhx
2026-08-28 23:36:11 +12:00

170 lines
7.5 KiB
Go

package api
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync"
"time"
)
// The console is a React application served by its own container, `memby-admin`, and the
// gateway reverse-proxies it.
//
// Why a proxy rather than a second published port: the household's reverse proxy sends one
// hostname to this gateway and nothing else, and the admin session is a cookie on that
// origin. A console on a port of its own would be a second origin — a second proxy rule to
// add by hand, CORS on every /admin/api route, and a cookie that has to be relaxed to
// SameSite=None to survive the crossing. Proxying keeps all of that as it was: same
// origin, same cookie, same single ingress, and `memby-admin` never needs to be reachable
// from outside the compose network.
//
// The /admin/api routes are *not* proxied. They are the gateway's own, matched by the mux
// before this ever sees them, which is what keeps the data path in-process and makes the
// console a purely presentational container that can be rebuilt and replaced on its own.
// adminUIRequestTimeout bounds a fetch of the console's own assets. Generous, because it
// covers a cold start where the memby-admin container is still coming up behind us, and
// bounded because a hung upstream must not hold a browser connection open indefinitely.
const adminUIRequestTimeout = 20 * time.Second
// adminUI builds the proxy lazily and once. It is lazy because the address is
// configuration and a gateway with no console configured must not fail to start, and it is
// once because a ReverseProxy carries a connection pool worth keeping.
func (s *Server) adminUI() *httputil.ReverseProxy {
s.adminUIOnce.Do(func() {
target := strings.TrimSpace(s.cfg.AdminUIURL)
if target == "" {
return
}
parsed, err := url.Parse(target)
if err != nil || parsed.Host == "" {
s.log.Error("admin console address is not a URL", "url", target)
return
}
proxy := httputil.NewSingleHostReverseProxy(parsed)
proxy.Transport = &http.Transport{
ResponseHeaderTimeout: adminUIRequestTimeout,
MaxIdleConnsPerHost: 4,
}
// The console is static files; a failure to fetch them is an operational fault
// worth a log line and a plain page, never a Go stack trace in the browser.
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
s.loggerFor(r.Context()).Error("admin console unreachable",
"path", r.URL.Path, "error", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(adminUnavailablePage))
}
s.adminUIProxy = proxy
})
return s.adminUIProxy
}
// adminUnavailablePage is what an operator sees when the gateway is up and the console
// container is not. It says which half is missing, because "502 Bad Gateway" over an admin
// URL reads as the whole server being down — which, this page being visible at all,
// it is not.
const adminUnavailablePage = `<!doctype html><meta charset="utf-8">` +
`<title>Memby admin</title>` +
`<style>body{margin:0;display:grid;place-items:center;min-height:100vh;` +
`background:#0a0c10;color:#e7ecf1;font:15px/1.6 system-ui,sans-serif}` +
`div{max-width:34rem;padding:2rem}h1{font-size:1.25rem;margin:0 0 .5rem}` +
`p{color:#8e99a6;margin:.4rem 0}code{color:#86dd7e}</style>` +
`<div><h1>The admin console is not answering</h1>` +
`<p>The Memby gateway is running — this page came from it — but the ` +
`<code>memby-admin</code> container that serves the console did not respond.</p>` +
`<p>Televisions are unaffected: the console is a separate container and the client ` +
`API is served from this process.</p>` +
`<p>Check <code>docker compose ps memby-admin</code> on the host.</p></div>`
// handleAdminConsole serves the single-page console for every /admin path that is not an
// API route.
//
// Client-side routing is why this is a catch-all rather than a route per page: the console
// owns its own URLs now, and a deep link, a refresh or the Back button all arrive here as
// an ordinary GET for a path this server has never heard of. The nav therefore lives in
// the React application and no longer has to be declared in Go as well — which is a real
// simplification, since the previous console had to keep adminNav, the rail, the page
// titles and the set of legal URLs agreeing with each other.
func (s *Server) handleAdminConsole(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
proxy := s.adminUI()
if proxy == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(adminUnavailablePage))
return
}
if !s.validAdminSession(r) {
// The sign-in form is still the gateway's, server-rendered, and returns to
// wherever the operator was trying to go. It is deliberately not part of the SPA:
// a login page that has to be downloaded from the thing it guards is one more
// moving part between an operator and a console they need precisely when
// something is wrong.
s.renderAccessLogin(w, r, "", http.StatusOK, r.URL.Path)
return
}
// A valid but anonymous session (minted before the cookie carried the operator's
// name) leaves the console unable to say who is signed in. Prompt for the shell only,
// so a fresh sign-in fills the name in without breaking asset requests mid-session.
if isAdminDocumentRequest(r) && !s.adminSessionNamed(r) {
s.renderAccessLogin(w, r, "Please sign in again to continue.",
http.StatusOK, r.URL.Path)
return
}
// Opening a page is somebody at the keyboard, so it starts the clock again.
s.renewAdminSession(w, r)
s.setAdminTokenCookie(w, r)
preventDiscovery(w)
// The shell must never be cached: it carries the asset hashes, so a stale copy points
// at JavaScript a deployment has already replaced. The hashed assets underneath it are
// cached hard by the console's own nginx, which is the usual arrangement and the
// reason those two rules must not be swapped.
if isAdminDocumentRequest(r) {
w.Header().Set("Cache-Control", "no-store")
}
proxy.ServeHTTP(w, r)
}
// isAdminDocumentRequest distinguishes the SPA shell from the assets it pulls in. Anything
// with a file extension is an asset; everything else is a console route, which the
// console's nginx answers with index.html.
func isAdminDocumentRequest(r *http.Request) bool {
path := r.URL.Path
if slash := strings.LastIndex(path, "/"); slash >= 0 {
path = path[slash+1:]
}
return !strings.Contains(path, ".")
}
// setAdminTokenCookie hands the browser the shared admin token, scoped to /admin.
//
// Unchanged from the previous console and worth restating: the cookie is HttpOnly, so the
// console's JavaScript never holds the token — it is attached by the browser to the API
// requests it makes, and adminAuth additionally requires a valid Emby-verified session
// alongside it. Neither half is sufficient on its own.
func (s *Server) setAdminTokenCookie(w http.ResponseWriter, r *http.Request) {
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,
})
}
// adminUIOnce/adminUIProxy live here rather than on the Server literal so this file holds
// the whole of the console-proxy concern.
type adminUIHandle struct {
adminUIOnce sync.Once
adminUIProxy *httputil.ReverseProxy
}