2026-08-14 09:40:03 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-15 21:11:43 +12:00
|
|
|
if !s.validAdminSession(r) {
|
2026-08-14 09:40:03 +12:00
|
|
|
// 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)
|
2026-08-28 23:36:11 +12:00
|
|
|
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)
|
2026-08-14 09:40:03 +12:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-15 21:11:43 +12:00
|
|
|
// Opening a page is somebody at the keyboard, so it starts the clock again.
|
2026-08-14 09:40:03 +12:00
|
|
|
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
|
|
|
|
|
}
|