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 = `` +
`
Memby admin` +
`` +
`The admin console is not answering
` +
`
The Memby gateway is running — this page came from it — but the ` +
`memby-admin container that serves the console did not respond.
` +
`
Televisions are unaffected: the console is a separate container and the client ` +
`API is served from this process.
` +
`
Check docker compose ps memby-admin on the host.
`
// 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
}
// 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
}