0.2.65 - End Credits wiring / Gateway: 0.1.45 - Credits

This commit is contained in:
ponzischeme89
2026-08-15 21:11:43 +12:00
parent d5d47473a2
commit e528d04b43
19 changed files with 574 additions and 65 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
}
}
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 ||
(browser && !s.validInstallerSession(r)) {
(browser && !s.validAdminSession(r)) {
writeError(w, http.StatusUnauthorized, "invalid admin token")
return
}
+192
View File
@@ -0,0 +1,192 @@
package api
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
)
// embyAccessLevel stands in for an Emby whose answer about a person's access level is
// under the test's control. userPolicy is the policy the authentication response carries
// ("" for a server that sends none, which is what sends the gate to ask again), and
// lookupPolicy is what a direct read of the account then answers ("" to fail that read).
func embyAccessLevel(t *testing.T, userPolicy, lookupPolicy string) *Server {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/Users/AuthenticateByName":
policy := ""
if userPolicy != "" {
policy = `,"Policy":` + userPolicy
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{
"User":{"Id":"emby-user","Name":"Viewer"`+policy+`},
"AccessToken":"temporary-emby-token",
"ServerId":"emby-server"
}`)
case r.URL.Path == "/Users/emby-user":
if lookupPolicy == "" {
http.Error(w, "no", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w,
`{"Id":"emby-user","Name":"Viewer","Policy":`+lookupPolicy+`}`)
case r.URL.Path == "/Sessions/Logout":
w.WriteHeader(http.StatusNoContent)
case r.URL.Path == "/Devices":
if r.Method == http.MethodGet {
_, _ = io.WriteString(w, `{"Items":[]}`)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(upstream.Close)
return &Server{
cfg: config.Config{
AdminToken: "secret",
ReleasePublishToken: "release-secret",
SyncUserID: "service-user", SyncAPIKey: "service-token",
},
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", 2*time.Second),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}
func signIn(s *Server, next string) *httptest.ResponseRecorder {
form := url.Values{
"username": {"Viewer"}, "password": {"correct horse"}, "next": {next},
}
req := httptest.NewRequest(http.MethodPost, "/install/login",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.handleInstallLogin(rec, req)
return rec
}
func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == installerCookieName {
return cookie
}
}
return nil
}
// The console administers the server, so knowing a household password is not the
// qualification for reaching it. Emby's own user policy is the authority on who may, and
// it is the one an operator already manages.
func TestAdminSignInRequiresAnEmbyAdministrator(t *testing.T) {
s := embyAccessLevel(t, `{"IsAdministrator":false}`, "")
rec := signIn(s, "/admin/")
if rec.Code != http.StatusForbidden {
t.Fatalf("non-administrator sign-in = %d, want 403", rec.Code)
}
if sessionCookie(rec) != nil {
t.Fatal("a refused admin sign-in issued a session anyway")
}
// The wording must not separate "wrong password" from "right password, wrong person":
// somebody who may not administer the server has no business learning either.
if !strings.Contains(rec.Body.String(), "Sign-in failed.") {
t.Fatalf("refusal discloses more than a failed sign-in: %s", rec.Body.String())
}
}
// The installer is the other half of the same gate and is public by design: a viewer
// putting Memby on a new television signs in there and must still be able to.
func TestNonAdministratorMayStillUseTheInstaller(t *testing.T) {
s := embyAccessLevel(t, `{"IsAdministrator":false}`, "")
rec := signIn(s, "/install")
if rec.Code != http.StatusSeeOther {
t.Fatalf("installer sign-in = %d, want 303", rec.Code)
}
cookie := sessionCookie(rec)
if cookie == nil {
t.Fatal("installer sign-in issued no session")
}
req := httptest.NewRequest(http.MethodGet, "/install", nil)
req.AddCookie(cookie)
if !s.validInstallerSession(req) {
t.Fatal("a viewer's installer session was not accepted by the installer")
}
if s.validAdminSession(req) {
t.Fatal("a viewer's installer session was accepted as an admin session")
}
}
func TestAdministratorSignInMintsAnAdminSession(t *testing.T) {
s := embyAccessLevel(t, `{"IsAdministrator":true}`, "")
rec := signIn(s, "/admin/overview")
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/overview" {
t.Fatalf("admin sign-in = %d %q", rec.Code, rec.Header().Get("Location"))
}
cookie := sessionCookie(rec)
if cookie == nil {
t.Fatal("admin sign-in issued no session")
}
if cookie.MaxAge != int(adminSessionTTL/time.Second) {
t.Fatalf("admin cookie MaxAge = %d, want %d",
cookie.MaxAge, int(adminSessionTTL/time.Second))
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(cookie)
if !s.validAdminSession(req) {
t.Fatal("an administrator's session was not accepted by the console")
}
}
// An Emby that returns no policy at all is a question, not a refusal — reading its silence
// as "not an administrator" would lock an operator out of their own console.
func TestAdminSignInAsksEmbyWhenTheAuthResponseCarriesNoPolicy(t *testing.T) {
s := embyAccessLevel(t, "", `{"IsAdministrator":true}`)
rec := signIn(s, "/admin/")
if rec.Code != http.StatusSeeOther {
t.Fatalf("sign-in = %d, want the policy to be read directly and accepted", rec.Code)
}
cookie := sessionCookie(rec)
if cookie == nil {
t.Fatal("no session issued after the access level was read directly")
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(cookie)
if !s.validAdminSession(req) {
t.Fatal("the session issued was not an admin session")
}
}
// And an Emby that will not answer either way must not be guessed at in the permissive
// direction: no answer means no session.
func TestAdminSignInIsRefusedWhenTheAccessLevelCannotBeRead(t *testing.T) {
s := embyAccessLevel(t, "", "")
rec := signIn(s, "/admin/")
if rec.Code != http.StatusBadGateway {
t.Fatalf("unreadable access level = %d, want 502", rec.Code)
}
if sessionCookie(rec) != nil {
t.Fatal("a session was issued without knowing the access level")
}
}
+2 -2
View File
@@ -100,7 +100,7 @@ func (s *Server) handleAdminConsole(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(adminUnavailablePage))
return
}
if !s.validInstallerSession(r) {
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
@@ -109,7 +109,7 @@ func (s *Server) handleAdminConsole(w http.ResponseWriter, r *http.Request) {
s.renderAccessLogin(w, r, "", http.StatusOK, r.URL.Path)
return
}
// Opening a page is somebody at the keyboard, so it starts the twelve-hour clock again.
// Opening a page is somebody at the keyboard, so it starts the clock again.
s.renewAdminSession(w, r)
s.setAdminTokenCookie(w, r)
preventDiscovery(w)
+52 -8
View File
@@ -123,13 +123,15 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
}
}
// installerSessionExpiring mints a session with a chosen life left, which is the only way
// to reach the renewal window without waiting six hours in a test.
func installerSessionExpiring(t *testing.T, s *Server, remaining time.Duration) *http.Cookie {
// browserSessionExpiring mints a session of one purpose with a chosen life left, which is
// the only way to reach the renewal window without waiting weeks in a test.
func browserSessionExpiring(
t *testing.T, s *Server, purpose string, remaining time.Duration,
) *http.Cookie {
t.Helper()
payload := make([]byte, 8+16)
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(remaining).Unix()))
signature := s.signInstallerValue("session", payload)
signature := s.signInstallerValue(purpose, payload)
return &http.Cookie{
Name: installerCookieName,
Value: base64.RawURLEncoding.EncodeToString(payload) + "." +
@@ -137,10 +139,15 @@ func installerSessionExpiring(t *testing.T, s *Server, remaining time.Duration)
}
}
func adminSessionExpiring(t *testing.T, s *Server, remaining time.Duration) *http.Cookie {
t.Helper()
return browserSessionExpiring(t, s, adminSessionPurpose, remaining)
}
func adminRequest(s *Server, remaining time.Duration, t *testing.T) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: s.cfg.AdminToken})
req.AddCookie(installerSessionExpiring(t, s, remaining))
req.AddCookie(adminSessionExpiring(t, s, remaining))
return req
}
@@ -182,7 +189,7 @@ func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) {
}
follow := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
follow.AddCookie(cookie)
expires, ok := server.installerSessionExpiry(follow)
expires, ok := server.browserSessionExpiry(follow, adminSessionPurpose)
if !ok || time.Until(expires) < adminSessionTTL-time.Minute {
t.Fatalf("renewed session should carry a full TTL, has %v (ok=%v)",
time.Until(expires), ok)
@@ -242,7 +249,7 @@ func TestAdminMutationExtendsTheSessionWithoutTheActivityHeader(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/admin/api/sync", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: server.cfg.AdminToken})
req.AddCookie(installerSessionExpiring(t, server, 2*time.Minute))
req.AddCookie(adminSessionExpiring(t, server, 2*time.Minute))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -550,7 +557,7 @@ func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
})
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: "secret"})
addInstallerSession(t, server, req)
req.AddCookie(adminSessionExpiring(t, server, adminSessionTTL))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -560,6 +567,43 @@ func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
}
}
// The installer is public: any member of the household signs in there to put Memby on a
// new television. One cookie serves both gates, so the console has to be able to tell the
// two sign-ins apart — otherwise a viewer's installer session reached /admin/, which then
// handed them the admin token cookie.
func TestInstallerSessionCannotReachTheAdminConsole(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: "secret"})
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("an installer session reached the admin API: %d", rec.Code)
}
}
// The other direction is deliberately allowed: somebody who may administer the server may
// certainly download the app.
func TestAdminSessionIsAcceptedByTheInstaller(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/install", nil)
req.AddCookie(adminSessionExpiring(t, server, adminSessionTTL))
if !server.validInstallerSession(req) {
t.Fatal("an administrator's session should satisfy the installer gate")
}
}
func TestAdminPageRequiresDiscreetEmbyGate(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
-20
View File
@@ -2,7 +2,6 @@ package api
import (
"context"
"fmt"
"time"
"github.com/ponzischeme89/memby/server/internal/scheduler"
@@ -98,18 +97,6 @@ func (s *Server) RegisterCreditsTasks(sched *scheduler.Scheduler) {
})
}
// creditsQueueDetail is the one line the admin console prints about the queue.
func (s *Server) creditsQueueDetail() string {
if s.credits == nil {
return ""
}
depth := s.credits.QueueDepth()
if depth == 0 {
return ""
}
return fmt.Sprintf("%d episode%s awaiting credits detection", depth, plural(depth))
}
// playbackSessionKey identifies one stream for the load gauge.
//
// The play session where Emby issued one, because that is what distinguishes two
@@ -122,10 +109,3 @@ func playbackSessionKey(deviceID, playSessionID string) string {
}
return deviceID
}
func plural(count int) string {
if count == 1 {
return ""
}
return "s"
}
+4 -3
View File
@@ -88,9 +88,10 @@ var featureCatalogue = []featureDefinition{
{
Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback",
Description: "Shrink the picture and run the closing credits at double speed with " +
"the next episode beside them, from the credits marker Emby writes. It is read " +
"from the same chapter list as the title sequence, so turning this off saves no " +
"request unless that is off too.",
"the next episode beside them. Emby's own marker is preferred where it has one, " +
"and where it has none the position discovered for the episodes the household " +
"is about to watch is used instead. It is read from the same chapter list as the " +
"title sequence, so turning this off saves no request unless that is off too.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
},
+86 -15
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
@@ -17,12 +18,29 @@ import (
const (
installerCookieName = "memby_installer"
installerSessionTTL = 30 * time.Minute
adminSessionTTL = 12 * time.Hour
installerDeviceID = "memby-web-installer"
// adminSessionTTL is deliberately long. The console is reached from the household's
// own machines, the sign-in behind it is an Emby password check, and an operator who
// opens it once a month was being asked for that password every single visit — which
// is the shape of a gate people work around rather than one that protects anything.
// Ninety days of idle time matches MEMBY_SESSION_IDLE_EXPIRY, so a browser and a
// television are forgotten on the same schedule.
adminSessionTTL = 90 * 24 * time.Hour
installerDeviceID = "memby-web-installer"
// adminRenewWithin is how close to expiry a session must be before an operator's own
// request re-issues it. Half the TTL avoids rewriting the cookie on every request.
adminRenewWithin = adminSessionTTL / 2
// A browser session says what it may be used for, and it says so by being signed with
// its own purpose rather than by carrying a claim the holder could edit. The two gates
// are not one gate: /install is public by design — any member of the household signs
// in there to install Memby on a new television — while the console administers the
// server. One cookie serves both, so without this separation an ordinary viewer's
// installer sign-in satisfied the console's gate, and opening /admin/ then handed them
// the admin token cookie. An admin session is accepted at /install as well, since
// somebody who may administer the server may certainly download the app.
installerSessionPurpose = "session"
adminSessionPurpose = "admin session"
)
// gatewayDeviceName is what Emby records for a device row the gateway creates for itself.
@@ -62,23 +80,24 @@ func (s *Server) signInstallerValue(purpose string, payload []byte) []byte {
}
func (s *Server) newInstallerSession() (string, error) {
return s.newBrowserSession(installerSessionTTL)
return s.newBrowserSession(installerSessionPurpose, installerSessionTTL)
}
func (s *Server) newBrowserSession(ttl time.Duration) (string, error) {
func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, error) {
payload := make([]byte, 8+16)
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix()))
if _, err := rand.Read(payload[8:]); err != nil {
return "", err
}
signature := s.signInstallerValue("session", payload)
signature := s.signInstallerValue(purpose, payload)
return base64.RawURLEncoding.EncodeToString(payload) + "." +
base64.RawURLEncoding.EncodeToString(signature), nil
}
// installerSessionExpiry reports when the request's session runs out. A cookie that is
// missing, malformed, forged or already expired is reported the same way: no session.
func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
// browserSessionExpiry reports when the request's session of this purpose runs out. A
// cookie that is missing, malformed, forged, signed for a different purpose or already
// expired is reported the same way: no session.
func (s *Server) browserSessionExpiry(r *http.Request, purpose string) (time.Time, bool) {
if len(s.installerSecret()) == 0 {
return time.Time{}, false
}
@@ -95,7 +114,7 @@ func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
return time.Time{}, false
}
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) {
if err != nil || !hmac.Equal(signature, s.signInstallerValue(purpose, payload)) {
return time.Time{}, false
}
expires := int64(binary.BigEndian.Uint64(payload[:8]))
@@ -106,8 +125,20 @@ func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
return time.Unix(expires, 0), true
}
// validInstallerSession gates the public installer, which an administrator's own session
// satisfies too.
func (s *Server) validInstallerSession(r *http.Request) bool {
_, ok := s.installerSessionExpiry(r)
if s.validAdminSession(r) {
return true
}
_, ok := s.browserSessionExpiry(r, installerSessionPurpose)
return ok
}
// validAdminSession gates the console. Only a sign-in Emby confirmed as an administrator
// mints one of these, so a household member's installer cookie cannot reach it.
func (s *Server) validAdminSession(r *http.Request) bool {
_, ok := s.browserSessionExpiry(r, adminSessionPurpose)
return ok
}
@@ -118,11 +149,11 @@ func (s *Server) validInstallerSession(r *http.Request) bool {
// an operator actually made — see operatorPresent — or an abandoned tab's own polling
// would keep the session alive indefinitely, which is what the TTL exists to stop.
func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) {
expires, ok := s.installerSessionExpiry(r)
expires, ok := s.browserSessionExpiry(r, adminSessionPurpose)
if !ok || time.Until(expires) > adminRenewWithin {
return
}
session, err := s.newBrowserSession(adminSessionTTL)
session, err := s.newBrowserSession(adminSessionPurpose, adminSessionTTL)
if err != nil {
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
return
@@ -183,6 +214,26 @@ func (s *Server) allowedReleaseDownload(r *http.Request, filename string) bool {
return expected != "" && hmac.Equal([]byte(presented), []byte(expected))
}
// embyAdministrator asks Emby whether the account that just signed in administers the
// server — the access level in its own user policy, which is the only authority on the
// question and the one an operator already manages. Emby answers it in the authentication
// response, so this normally costs nothing; a response carrying no policy at all is asked
// again directly rather than read as a refusal, because reading silence as "no" would lock
// an operator out of their own console with no way back in.
func (s *Server) embyAdministrator(ctx context.Context, auth *emby.AuthResult) (bool, error) {
if auth.User.Policy.IsAdministrator != nil {
return *auth.User.Policy.IsAdministrator, nil
}
user, err := s.emby.UserByID(ctx, emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true,
}, auth.User.ID)
if err != nil {
return false, err
}
return user.Policy.IsAdministrator, nil
}
func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
if len(s.installerSecret()) == 0 {
http.NotFound(w, r)
@@ -222,6 +273,10 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next)
return
}
// Ask before the token is retired below: the fallback lookup needs it. Whether the
// answer is wanted depends on where the sign-in was headed, but it is asked either way
// so that the cleanup underneath runs on one path rather than two.
administrator, adminErr := s.embyAdministrator(r.Context(), auth)
// Authentication creates an Emby access token. The installer needs only proof that
// it succeeded, so retire the upstream session immediately and never persist it.
if err := s.emby.Logout(r.Context(), emby.Credentials{
@@ -242,11 +297,27 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
return
}
ttl := installerSessionTTL
purpose, ttl := installerSessionPurpose, installerSessionTTL
if strings.HasPrefix(next, "/admin/") {
ttl = adminSessionTTL
if adminErr != nil {
s.loggerFor(r.Context()).Error("admin sign-in could not read Emby access level",
"user", username, "error", adminErr)
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
http.StatusBadGateway, next)
return
}
if !administrator {
// Deliberately the same wording an unknown password gets. Somebody who is not
// an administrator has no business learning that the console exists and that
// their password was right; the operator can see the refusal in the log.
s.loggerFor(r.Context()).Warn("admin sign-in refused: not an Emby administrator",
"user", username)
s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusForbidden, next)
return
}
purpose, ttl = adminSessionPurpose, adminSessionTTL
}
session, err := s.newBrowserSession(ttl)
session, err := s.newBrowserSession(purpose, ttl)
if err != nil {
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start installer session")
+2 -1
View File
@@ -252,7 +252,8 @@ func TestInstallerLoginUsesEmbyWithoutCreatingTVSession(t *testing.T) {
case "/Users/AuthenticateByName":
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{
"User":{"Id":"emby-user","Name":"Matt"},
"User":{"Id":"emby-user","Name":"Matt",
"Policy":{"IsAdministrator":true}},
"AccessToken":"temporary-emby-token",
"ServerId":"emby-server"
}`)