0.3.22 - PINs

This commit is contained in:
ponzischeme89
2026-08-25 11:39:55 +12:00
parent 396d35e2f5
commit 0fcc02f57e
2697 changed files with 5360 additions and 50 deletions
+69 -5
View File
@@ -23,7 +23,7 @@ import (
// since been unplugged.
type adminViewersResponse struct {
Viewers []store.Viewer `json:"viewers"`
Viewers []adminViewer `json:"viewers"`
// Whether the household's own switch is on. The page says so rather than quietly
// offering controls whose effect nothing on any television would show: an operator who
// has switched viewers off and then adds one has done something that looks like it
@@ -34,6 +34,11 @@ type adminViewersResponse struct {
MaxShadowViewers int `json:"maxShadowViewers"`
}
type adminViewer struct {
store.Viewer
PIN string `json:"pin,omitempty"`
}
func (s *Server) handleAdminViewers(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
@@ -53,8 +58,17 @@ func (s *Server) handleAdminViewers(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "could not load viewers")
return
}
admin := make([]adminViewer, 0, len(viewers))
for _, viewer := range viewers {
pin, pinErr := s.store.ViewerPIN(r.Context(), userID, viewer.ID)
if pinErr != nil {
writeError(w, http.StatusInternalServerError, "could not load PINs")
return
}
admin = append(admin, adminViewer{Viewer: viewer, PIN: pin})
}
writeJSON(w, http.StatusOK, adminViewersResponse{
Viewers: viewers,
Viewers: admin,
Enabled: s.viewersEnabled(r.Context()),
MaxShadowViewers: store.MaxShadowViewers,
})
@@ -70,11 +84,31 @@ func (s *Server) handleAdminCreateViewer(w http.ResponseWriter, r *http.Request,
writeError(w, http.StatusBadRequest, "a name of up to 40 characters is required")
return
}
var requestedPINHash []byte
if req.PIN != "" {
var err error
requestedPINHash, err = pinHash(req.PIN)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
viewer, err := s.store.CreateShadowViewer(r.Context(), userID, req.Name, req.ShortName, req.Colour)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.PIN != "" {
if err := s.store.SetViewerPIN(r.Context(), userID, viewer.ID, requestedPINHash); err != nil {
writeError(w, http.StatusInternalServerError, "could not save PIN")
return
}
if err := s.store.SetViewerPINValue(r.Context(), userID, viewer.ID, req.PIN); err != nil {
writeError(w, http.StatusInternalServerError, "could not save PIN")
return
}
viewer.HasPIN = true
}
// The televisions hold a cached list for viewerListTTL, so the write clears it here for
// the same reason it does on the client-facing route: a person added from the console
// must be pickable on the next request rather than at the end of the window.
@@ -117,9 +151,17 @@ func (s *Server) handleAdminViewer(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
viewer, err := s.store.UpdateShadowViewer(
r.Context(), userID, viewerID, req.Name, req.ShortName, req.Colour,
)
var viewer store.Viewer
var err error
// The main viewer cannot be renamed here, but its PIN is an account credential and
// administrators must still be able to set or reset it.
if viewerID == userID && (req.PIN != "" || req.ClearPIN) {
viewer, err = s.store.ViewerFor(r.Context(), userID, viewerID)
} else {
viewer, err = s.store.UpdateShadowViewer(
r.Context(), userID, viewerID, req.Name, req.ShortName, req.Colour,
)
}
if err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
// The main viewer lands here too, and that is the honest answer: its name is
@@ -130,6 +172,28 @@ func (s *Server) handleAdminViewer(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.ClearPIN || req.PIN != "" {
if req.ClearPIN {
err = s.store.ClearViewerPIN(r.Context(), userID, viewerID)
if err == nil {
err = s.store.SetViewerPINValue(r.Context(), userID, viewerID, "")
}
} else {
var hash []byte
hash, err = pinHash(req.PIN)
if err == nil {
err = s.store.SetViewerPIN(r.Context(), userID, viewerID, hash)
if err == nil {
err = s.store.SetViewerPINValue(r.Context(), userID, viewerID, req.PIN)
}
}
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
viewer.HasPIN = !req.ClearPIN
}
s.forgetViewers(userID)
s.loggerFor(r.Context()).Info("viewer renamed by operator",
"account", userID, "viewer", viewer.ID, "name", viewer.Name)
+3
View File
@@ -265,6 +265,8 @@ func (s *Server) Routes() http.Handler {
v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.requireSupportedClient(s.handleLogin))
v1.HandleFunc("GET /v1/auth/recovery", s.handleRecoveryProfiles)
v1.HandleFunc("POST /v1/auth/recovery", s.requireSupportedClient(s.handleRecovery))
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
v1.Handle("GET /v1/auth/devices", s.authed(s.handleDevices))
@@ -276,6 +278,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/viewers", s.authed(s.handleViewers))
v1.Handle("POST /v1/viewers", s.authed(s.handleViewers))
v1.Handle("PUT /v1/viewers/{viewerID}", s.authed(s.handleViewer))
v1.Handle("PUT /v1/viewers/{viewerID}/pin", s.authed(s.handleViewerPIN))
v1.Handle("DELETE /v1/viewers/{viewerID}", s.authed(s.handleViewer))
v1.Handle("GET /v1/home", s.authed(s.handleHome))
+27
View File
@@ -420,6 +420,33 @@ func TestHomeForYouWindowAt(t *testing.T) {
}
}
func TestClientLocalNowPrefersTheClientHeaderOverTheHousehold(t *testing.T) {
household := time.Date(2026, time.July, 29, 23, 30, 0, 0, time.UTC)
r := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
r.Header.Set(clientLocalHourHeader, "7")
got := clientLocalNow(r, household)
if got.Hour() != 7 {
t.Fatalf("hour = %d, want 7", got.Hour())
}
if got.Year() != 2026 || got.Month() != time.July || got.Day() != 29 {
t.Fatalf("date changed unexpectedly: %v", got)
}
}
func TestClientLocalNowFallsBackWithoutAUsableHeader(t *testing.T) {
household := time.Date(2026, time.July, 29, 23, 30, 0, 0, time.UTC)
for _, header := range []string{"", "not-a-number", "24", "-1"} {
r := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
if header != "" {
r.Header.Set(clientLocalHourHeader, header)
}
if got := clientLocalNow(r, household); !got.Equal(household) {
t.Fatalf("header %q: got %v, want fallback %v", header, got, household)
}
}
}
func TestRecommendationBuildsAreDeduplicatedPerUser(t *testing.T) {
var builds recommendationBuilds
+96
View File
@@ -3,12 +3,15 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
@@ -41,6 +44,20 @@ type renameDeviceRequest struct {
DeviceName string `json:"deviceName"`
}
type recoveryRequest struct {
DeviceID string `json:"deviceId"`
ViewerID string `json:"viewerId"`
PIN string `json:"pin"`
}
type recoveryProfileResponse struct {
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
UserID string `json:"userId"`
Username string `json:"username"`
Viewer store.Viewer `json:"viewer"`
}
// handleLogin exchanges Emby credentials for a gateway token.
//
// The Emby access token stays here: the TV only ever holds the gateway token, so
@@ -227,6 +244,85 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
})
}
// handleRecoveryProfiles is intentionally unauthenticated: its only credential is the
// derived device id. It returns no upstream token or PIN material.
func (s *Server) handleRecoveryProfiles(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "device id is required")
return
}
profiles, err := s.store.DeviceRecoveryProfiles(r.Context(), deviceID)
if err != nil {
s.log.Error("device recovery lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not check this device")
return
}
out := make([]recoveryProfileResponse, 0, len(profiles))
for _, p := range profiles {
out = append(out, recoveryProfileResponse{p.DeviceID, p.DeviceName, p.UserID, p.Username, p.Viewer})
}
writeJSON(w, http.StatusOK, map[string]any{"profiles": out})
}
// handleRecovery turns a valid profile PIN into a fresh ordinary gateway session. The
// upstream Emby token remains server-side, exactly like a password sign-in.
func (s *Server) handleRecovery(w http.ResponseWriter, r *http.Request) {
var req recoveryRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
profiles, err := s.store.DeviceRecoveryProfiles(r.Context(), strings.TrimSpace(req.DeviceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not check this device")
return
}
var chosen *store.DeviceRecoveryProfile
for i := range profiles {
if profiles[i].Viewer.ID == strings.TrimSpace(req.ViewerID) {
chosen = &profiles[i]
break
}
}
if chosen == nil {
writeError(w, http.StatusUnauthorized, "profile recovery failed")
return
}
valid, pinErr := s.store.CheckViewerPIN(r.Context(), chosen.UserID, chosen.Viewer.ID, func(hash []byte) bool {
return bcrypt.CompareHashAndPassword(hash, []byte(req.PIN)) == nil
})
if pinErr != nil || !valid {
s.loggerFor(r.Context()).Warn("profile PIN rejected", "device_id", req.DeviceID, "viewer_id", req.ViewerID, "reason", pinReason(pinErr))
writeError(w, http.StatusUnauthorized, "incorrect PIN")
return
}
token, err := newToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue a token")
return
}
sess := store.Session{TokenHash: hashToken(token), EmbyUserID: chosen.UserID, EmbyToken: chosen.EmbyToken, Username: chosen.Username, ServerID: chosen.ServerID, DeviceID: chosen.DeviceID, DeviceName: chosen.DeviceName, ClientVersion: clientVersion(r), ClientProtocol: clientProtocol(r), ClientCapabilities: clientCapabilities(r)}
created, err := s.store.CreateSession(r.Context(), sess)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start a session")
return
}
if len(created.ReplacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(created.ReplacedHash)))
}
s.cacheSession(r.Context(), sess)
s.recordLogin(r, store.LoginEvent{EmbyUserID: sess.EmbyUserID, Username: sess.Username, DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, ClientVersion: sess.ClientVersion, Success: true, Method: store.LoginMethodPIN})
writeJSON(w, http.StatusOK, loginResponse{Token: token, UserID: sess.EmbyUserID, Username: sess.Username, ServerID: sess.ServerID})
}
func pinReason(err error) string {
if errors.Is(err, store.ErrPINLocked) {
return "temporarily locked"
}
return "invalid"
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store.Session) {
if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil {
s.log.Error("session delete failed", "error", err)
+123
View File
@@ -80,6 +80,121 @@ var configurationCatalogue = []configurationDefinition{
func intPtr(v int) *int { return &v }
// rowTypeDefinition is the vocabulary behind the "row type" picker in the admin console's
// visual row editor. An operator configuring Home, Movies or TV composition should never
// need to know that a Continue Watching row is a "mediaRow" reading "emby.resume" — they
// pick the type, and component/dataSource follow from it. It exists so the client's fixed,
// small vocabulary of renderable components (mediaRow, mediaGrid, genreBrowser) is described
// once, here, rather than the admin console guessing at it independently and drifting from
// what a television actually understands.
type rowTypeDefinition struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
Component string `json:"component"`
DataSource string `json:"dataSource"`
// Pages lists which of Home/Movies/TV this type may be placed on. A row whose type
// is not valid for the page it is being saved to is exactly the "unsupported row/page
// combination" the visual editor exists to make impossible.
Pages []string `json:"pages"`
// RequiresMatchingDestination is true for a type whose row only makes sense pointing at
// the page it lives on (Genres/Library on Movies must browse Movies, never TV).
RequiresMatchingDestination bool `json:"requiresMatchingDestination"`
// Custom withholds component/dataSource entirely: the operator states them directly,
// which is the escape hatch for a row this catalogue has no opinion about yet.
Custom bool `json:"custom"`
}
var rowTypeCatalogue = []rowTypeDefinition{
{Type: "continueWatching", Label: "Continue Watching", Description: "Resumable films and unwatched next episodes, merged and ordered by recency.", Component: "mediaRow", DataSource: "emby.resume", Pages: []string{"home"}},
{Type: "forYou", Label: "For You", Description: "Personalised recommendations built from this household's viewing history.", Component: "mediaRow", DataSource: "gateway.recommendations", Pages: []string{"home"}},
{Type: "favorites", Label: "Favourites", Description: "Titles marked as favourites.", Component: "mediaRow", DataSource: "emby.favourites", Pages: []string{"home"}},
{Type: "latest", Label: "Recently Added", Description: "The newest titles added to the library.", Component: "mediaRow", DataSource: "emby.latest", Pages: []string{"home"}},
{Type: "genres", Label: "Genre Browser", Description: "A full-width row of genre categories for browsing by taste.", Component: "genreBrowser", DataSource: "emby.genres", Pages: []string{"movies", "tv"}, RequiresMatchingDestination: true},
{Type: "library", Label: "Library Grid", Description: "A paged grid of the whole catalogue for this page.", Component: "mediaGrid", DataSource: "emby.library", Pages: []string{"movies", "tv"}, RequiresMatchingDestination: true},
{Type: "custom", Label: "Custom", Description: "Set the data source and component directly. For development and configurations this catalogue does not yet describe.", Pages: []string{"home", "movies", "tv"}, Custom: true},
}
func rowTypeDefinitionFor(rowType string) (rowTypeDefinition, bool) {
for _, definition := range rowTypeCatalogue {
if definition.Type == rowType {
return definition, true
}
}
return rowTypeDefinition{}, false
}
// sectionDefinitionPageKeys maps a configuration key to the page it composes, which is what
// lets validateSectionDefinitions refuse a row type placed on a page it does not support.
var sectionDefinitionPageKeys = map[string]string{
"home.sectionDefinitions": "home",
"movies.sectionDefinitions": "movies",
"tv.sectionDefinitions": "tv",
}
// validateSectionDefinitions is the structural half of what the visual row editor promises:
// invalid configurations are impossible to save, not merely discouraged. It runs beside the
// generic "json" type check in validateConfigurationValue, which only confirms the value is
// an array at all.
func validateSectionDefinitions(key string, raw json.RawMessage) error {
page, ok := sectionDefinitionPageKeys[key]
if !ok {
return nil
}
var definitions []config.RemoteSectionDefinition
if err := json.Unmarshal(raw, &definitions); err != nil {
return errors.New(key + ": invalid section definitions")
}
if len(definitions) > 32 {
return errors.New(key + ": too many rows")
}
seenIDs := make(map[string]bool, len(definitions))
seenPositions := make(map[int]bool, len(definitions))
for _, definition := range definitions {
id := strings.TrimSpace(definition.ID)
if id == "" {
return errors.New(key + ": every row needs an id")
}
if seenIDs[id] {
return errors.New(key + ": duplicate row id " + id)
}
seenIDs[id] = true
if strings.TrimSpace(definition.Title) == "" {
return errors.New(key + ": row " + id + " needs a title")
}
if definition.Position < 0 {
return errors.New(key + ": row " + id + " has an invalid position")
}
if seenPositions[definition.Position] {
return errors.New(key + ": duplicate row position for " + id)
}
seenPositions[definition.Position] = true
if definition.MaxItems < 0 || definition.MaxItems > 100 {
return errors.New(key + ": row " + id + " has an invalid maximum item count")
}
if strings.TrimSpace(definition.Component) == "" {
return errors.New(key + ": row " + id + " needs a component")
}
rowType, known := rowTypeDefinitionFor(definition.Type)
if !known || rowType.Custom {
continue
}
if !slices.Contains(rowType.Pages, page) {
return errors.New(key + ": " + rowType.Label + " rows are not supported on this page")
}
if definition.Component != rowType.Component {
return errors.New(key + ": row " + id + " has a component that does not match its row type")
}
if definition.DataSource != rowType.DataSource {
return errors.New(key + ": row " + id + " has a data source that does not match its row type")
}
if rowType.RequiresMatchingDestination && definition.Destination != page {
return errors.New(key + ": row " + id + " must target the " + page + " page")
}
}
return nil
}
var featureCatalogue = []featureDefinition{
{
Key: featureSonarrPreroll, Name: "Sonarr upcoming preroll", Area: "Playback",
@@ -236,6 +351,10 @@ type featureResponse struct {
CanRollback bool `json:"canRollback"`
Features []evaluatedFeature `json:"features"`
Configuration []evaluatedConfiguration `json:"configuration"`
// RowTypes is the static catalogue behind the console's visual row editor. It travels
// with every feature-policy response rather than a dedicated endpoint, because it is
// read alongside Configuration and never changes independently of a server release.
RowTypes []rowTypeDefinition `json:"rowTypes"`
}
type evaluatedConfiguration struct {
@@ -390,6 +509,7 @@ func featurePayloadForSession(policy store.FeaturePolicy, protocol int, session
SchemaVersion: featureSchemaVersion, Revision: policy.Revision,
SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt,
CanRollback: policy.Previous != nil, Features: features,
RowTypes: rowTypeCatalogue,
}
if session != nil {
response.Configuration = configurationPayload(policy, *session)
@@ -555,6 +675,9 @@ func validateConfigurationValue(definition configurationDefinition, raw json.Raw
if _, ok := value.([]any); !ok {
return errors.New("configuration value must be a JSON array: " + definition.Key)
}
if err := validateSectionDefinitions(definition.Key, raw); err != nil {
return err
}
case "string":
if _, ok := value.(string); !ok {
return errors.New("configuration value must be text: " + definition.Key)
+79
View File
@@ -136,6 +136,85 @@ func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
}
}
func TestValidateSectionDefinitionsRejectsDuplicateIDs(t *testing.T) {
raw := json.RawMessage(`[
{"id":"a","type":"favorites","title":"Favourites","enabled":true,"position":10,"dataSource":"emby.favourites","component":"mediaRow","destination":"home"},
{"id":"a","type":"latest","title":"Recently Added","enabled":true,"position":20,"dataSource":"emby.latest","component":"mediaRow","destination":"home"}
]`)
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
t.Fatal("duplicate row id was accepted")
}
}
func TestValidateSectionDefinitionsRejectsDuplicatePositions(t *testing.T) {
raw := json.RawMessage(`[
{"id":"a","type":"favorites","title":"Favourites","enabled":true,"position":10,"dataSource":"emby.favourites","component":"mediaRow","destination":"home"},
{"id":"b","type":"latest","title":"Recently Added","enabled":true,"position":10,"dataSource":"emby.latest","component":"mediaRow","destination":"home"}
]`)
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
t.Fatal("duplicate row position was accepted")
}
}
func TestValidateSectionDefinitionsRejectsMissingTitle(t *testing.T) {
raw := json.RawMessage(`[{"id":"a","type":"favorites","title":"","enabled":true,"position":10,"dataSource":"emby.favourites","component":"mediaRow","destination":"home"}]`)
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
t.Fatal("missing title was accepted")
}
}
func TestValidateSectionDefinitionsRejectsInvalidMaxItems(t *testing.T) {
raw := json.RawMessage(`[{"id":"a","type":"favorites","title":"Favourites","enabled":true,"position":10,"maxItems":500,"dataSource":"emby.favourites","component":"mediaRow","destination":"home"}]`)
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
t.Fatal("out-of-range maxItems was accepted")
}
}
func TestValidateSectionDefinitionsRejectsUnsupportedRowPageCombination(t *testing.T) {
// Genres is a Movies/TV row type; it has no business on Home.
raw := json.RawMessage(`[{"id":"a","type":"genres","title":"Genres","enabled":true,"position":10,"dataSource":"emby.genres","component":"genreBrowser","destination":"movies"}]`)
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
t.Fatal("genres row was accepted on the Home page")
}
}
func TestValidateSectionDefinitionsRejectsIncompatibleDataSource(t *testing.T) {
raw := json.RawMessage(`[{"id":"a","type":"favorites","title":"Favourites","enabled":true,"position":10,"dataSource":"emby.latest","component":"mediaRow","destination":"home"}]`)
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
t.Fatal("mismatched data source was accepted")
}
}
func TestValidateSectionDefinitionsRejectsDestinationMismatchedToPage(t *testing.T) {
raw := json.RawMessage(`[{"id":"a","type":"genres","title":"Genres","enabled":true,"position":10,"dataSource":"emby.genres","component":"genreBrowser","destination":"tv"}]`)
if err := validateSectionDefinitions("movies.sectionDefinitions", raw); err == nil {
t.Fatal("genres row on Movies pointed at TV was accepted")
}
}
func TestValidateSectionDefinitionsAcceptsTheDefaultDocument(t *testing.T) {
for key, definitions := range map[string][]config.RemoteSectionDefinition{
"home.sectionDefinitions": config.DefaultRemoteConfig().Home.SectionDefinitions,
"movies.sectionDefinitions": config.DefaultRemoteConfig().Movies.SectionDefinitions,
"tv.sectionDefinitions": config.DefaultRemoteConfig().TV.SectionDefinitions,
} {
raw, err := json.Marshal(definitions)
if err != nil {
t.Fatalf("marshal %s: %v", key, err)
}
if err := validateSectionDefinitions(key, raw); err != nil {
t.Fatalf("default %s was rejected: %v", key, err)
}
}
}
func TestValidateSectionDefinitionsAllowsCustomRowsOutsideTheCatalogue(t *testing.T) {
raw := json.RawMessage(`[{"id":"a","type":"custom","title":"Something new","enabled":true,"position":10,"dataSource":"gateway.whatever","component":"mediaRow","destination":"home"}]`)
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err != nil {
t.Fatalf("custom row was rejected: %v", err)
}
}
func TestFeatureAdminRejectsUnknownFlagsBeforeWriting(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodPost, "/admin/api/features",
+48 -6
View File
@@ -41,7 +41,10 @@ const (
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ProviderIds,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
// Thumb rides every row, not only Continue Watching's: rowParams is the one query
// shape every row shares, and Emby only reports ParentThumbItemId/ParentThumbImageTag
// on an item when Thumb is among the requested types.
rowImageTypes = "Backdrop,Primary,Logo,Thumb"
screensaverImageTypes = "Backdrop,Logo"
)
@@ -90,6 +93,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
continueDefinition,
ProtocolVersion,
).Enabled
// The For You window is the viewer's own device clock, not the household's: the
// greeting above this row already draws from the TV's local time, and a gateway
// hosted (or configured) in a different zone must not tell somebody "Good morning"
// while leading with "Late-night picks for you". See clientLocalNow.
forYouWindow := homeForYouWindowAt(clientLocalNow(r, now.In(s.householdLocation())))
// The hero revision is part of the key rather than something to invalidate. An
// operator's change therefore makes the entries built under the old policy simply
// unreachable, and they age out on their own TTL — where the sweep it replaced dropped
@@ -102,7 +110,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
":c"+strconv.FormatBool(continueWatching)+
":f"+strconv.FormatInt(featurePolicy.Revision, 10)+
":hr"+heroRev+":d"+sess.DeviceID,
":hr"+heroRev+":w"+forYouWindow.ID+":d"+sess.DeviceID,
)
if raw, err := s.cache.Get(ctx, key); err == nil {
@@ -298,9 +306,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
wg.Add(1)
go func() {
defer wg.Done()
location := s.householdLocation()
window := homeForYouWindowAt(time.Now().In(location))
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, forYouWindow.Minutes)
if err != nil {
s.loggerFor(ctx).Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
return
@@ -308,7 +314,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if !hit || len(prepared) == 0 {
return
}
homeRows := preparedHomeForYouRows(prepared, window)
homeRows := preparedHomeForYouRows(prepared, forYouWindow)
if len(homeRows) == 0 {
return
}
@@ -850,6 +856,42 @@ type homeForYouWindow struct {
Minutes int
}
// clientLocalHourHeader carries the viewer's own device clock, in hours, on every request.
// It exists so a time-sensitive row can agree with the greeting drawn above it: the
// greeting is the TV's own local time, and a household's configured timezone (or the
// gateway's host clock, when none is set) is a different fact that happens to usually
// agree with it — usually, not always, and the times it disagrees are exactly what "Good
// morning" beside "Late-night picks for you" looks like.
const clientLocalHourHeader = "X-Memby-Local-Hour"
// clientLocalNow substitutes the client's reported hour into householdFallback, so an
// older client (or one that failed to send the header) still gets an answer — the
// household's own idea of the time, which is what this row used before there was
// anything to disagree with it.
func clientLocalNow(r *http.Request, householdFallback time.Time) time.Time {
hour, ok := parseClientLocalHour(r.Header.Get(clientLocalHourHeader))
if !ok {
return householdFallback
}
return time.Date(
householdFallback.Year(), householdFallback.Month(), householdFallback.Day(),
hour, householdFallback.Minute(), householdFallback.Second(), 0,
householdFallback.Location(),
)
}
func parseClientLocalHour(value string) (int, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
hour, err := strconv.Atoi(value)
if err != nil || hour < 0 || hour > 23 {
return 0, false
}
return hour, true
}
// homeForYouWindowAt keeps Home useful without asking the viewer for a duration.
// These deliberately broad windows suit a household TV: short before lunch, an
// episode-sized pick in the afternoon/late evening, and film headroom at night.
+7 -4
View File
@@ -395,10 +395,13 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
}
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
"AdjacentTo": {itemID},
"Fields": {"RunTimeTicks,Overview,SeriesName"},
"EnableUserData": {"true"},
"EnableImageTypes": {"Primary,Thumb"},
"AdjacentTo": {itemID},
"Fields": {"RunTimeTicks,Overview,SeriesName"},
"EnableUserData": {"true"},
// Logo alongside the row's own artwork, or the next-up banner has nothing to
// draw a title treatment from — Emby folds the series' logo into an episode's
// ParentLogoItemId/ParentLogoImageTag only for image types this request asks for.
"EnableImageTypes": {"Primary,Thumb,Logo"},
})
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load the next episode")
+48
View File
@@ -9,6 +9,8 @@ import (
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -275,6 +277,52 @@ type viewerRequest struct {
Name string `json:"name"`
ShortName string `json:"shortName"`
Colour string `json:"colour"`
PIN string `json:"pin,omitempty"`
ClearPIN bool `json:"clearPin,omitempty"`
}
type viewerPINRequest struct {
PIN string `json:"pin"`
}
func (s *Server) handleViewerPIN(w http.ResponseWriter, r *http.Request, sess store.Session) {
var req viewerPINRequest
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req) != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
viewerID := strings.TrimSpace(r.PathValue("viewerID"))
hash, err := pinHash(req.PIN)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not save PIN")
return
}
if err := s.store.SetViewerPIN(r.Context(), sess.EmbyUserID, viewerID, hash); err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
writeError(w, http.StatusNotFound, "no such viewer")
return
}
writeError(w, http.StatusInternalServerError, "could not save PIN")
return
}
if err := s.store.SetViewerPINValue(r.Context(), sess.EmbyUserID, viewerID, req.PIN); err != nil {
writeError(w, http.StatusInternalServerError, "could not save PIN")
return
}
s.forgetViewers(sess.EmbyUserID)
writeJSON(w, http.StatusOK, map[string]bool{"saved": true})
}
func pinHash(pin string) ([]byte, error) {
if len(pin) < 4 || len(pin) > 12 {
return nil, errors.New("PIN must be 4 to 12 characters")
}
for _, r := range pin {
if r < '0' || r > '9' {
return nil, errors.New("PIN must contain only numbers")
}
}
return bcrypt.GenerateFromPassword([]byte(pin), bcrypt.DefaultCost)
}
func (s *Server) handleViewers(w http.ResponseWriter, r *http.Request, sess store.Session) {
+1
View File
@@ -19,6 +19,7 @@ const LoginRetention = 90 * 24 * time.Hour
// them apart without inferring it from the device name.
const (
LoginMethodPassword = "password" // a television exchanging Emby credentials
LoginMethodPIN = "pin" // a television recovering a stored device session
LoginMethodAdmin = "admin" // an operator signing into the admin console
LoginMethodInstaller = "installer" // the web installer's own password check
)
+9
View File
@@ -945,10 +945,19 @@ CREATE TABLE IF NOT EXISTS viewers (
colour TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- main | shadow
pin_hash BYTEA,
pin_value TEXT,
pin_failed_attempts INTEGER NOT NULL DEFAULT 0,
pin_locked_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Failed PINs are kept with the viewer rather than in the television. This survives
-- reinstalls and gives every device sharing a profile the same brute-force budget.
ALTER TABLE viewers ADD COLUMN IF NOT EXISTS pin_failed_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE viewers ADD COLUMN IF NOT EXISTS pin_locked_until TIMESTAMPTZ;
ALTER TABLE viewers ADD COLUMN IF NOT EXISTS pin_value TEXT;
CREATE INDEX IF NOT EXISTS viewers_account_idx ON viewers (emby_user_id, created_at);
-- One main viewer per account, enforced rather than assumed: the main viewer is what a
+121
View File
@@ -38,6 +38,19 @@ type Viewer struct {
CreatedAt time.Time `json:"createdAt"`
}
type DeviceRecoveryProfile struct {
DeviceID string
DeviceName string
UserID string
Username string
ServerID string
Viewer Viewer
EmbyToken string
}
var ErrPINLocked = errors.New("store: pin temporarily locked")
var ErrPINInvalid = errors.New("store: invalid pin")
// IsMain reports whether this viewer's state is published to Emby.
func (v Viewer) IsMain() bool { return v.Kind == ViewerMain }
@@ -89,6 +102,114 @@ func (s *Store) Viewers(ctx context.Context, embyUserID, username string) ([]Vie
return viewers, rows.Err()
}
// DeviceRecoveryProfiles is deliberately based on the surviving gateway session, not
// on IP address or device name. A reinstall loses local credentials but not this record.
func (s *Store) DeviceRecoveryProfiles(ctx context.Context, deviceID string) ([]DeviceRecoveryProfile, error) {
rows, err := s.pool.Query(ctx, `
SELECT s.device_id, s.device_name, s.emby_user_id, s.username, s.server_id, s.emby_token
FROM sessions s WHERE s.device_id = $1 ORDER BY s.last_seen_at DESC LIMIT 1`, deviceID)
if err != nil {
return nil, fmt.Errorf("store: device recovery profiles: %w", err)
}
defer rows.Close()
profiles := []DeviceRecoveryProfile{}
for rows.Next() {
var p DeviceRecoveryProfile
if err := rows.Scan(&p.DeviceID, &p.DeviceName, &p.UserID, &p.Username, &p.ServerID, &p.EmbyToken); err != nil {
return nil, fmt.Errorf("store: scan recovery profile: %w", err)
}
viewers, err := s.Viewers(ctx, p.UserID, p.Username)
if err != nil {
return nil, err
}
for _, viewer := range viewers {
copy := p
copy.Viewer = viewer
profiles = append(profiles, copy)
}
}
return profiles, rows.Err()
}
func (s *Store) SetViewerPIN(ctx context.Context, embyUserID, viewerID string, hash []byte) error {
result, err := s.pool.Exec(ctx, `UPDATE viewers SET pin_hash = $3, pin_failed_attempts = 0,
pin_locked_until = NULL, updated_at = now() WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, hash)
if err != nil {
return fmt.Errorf("store: set viewer pin: %w", err)
}
if result.RowsAffected() == 0 {
return ErrViewerNotFound
}
return nil
}
func (s *Store) SetViewerPINValue(ctx context.Context, embyUserID, viewerID, pin string) error {
_, err := s.pool.Exec(ctx, `UPDATE viewers SET pin_value = $3 WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, nilIfEmpty(pin))
return err
}
func nilIfEmpty(value string) any {
if value == "" {
return nil
}
return value
}
func (s *Store) ClearViewerPIN(ctx context.Context, embyUserID, viewerID string) error {
return s.SetViewerPIN(ctx, embyUserID, viewerID, nil)
}
// ViewerPIN is intentionally not part of Viewer: only the admin handler may ask for it.
func (s *Store) ViewerPIN(ctx context.Context, embyUserID, viewerID string) (string, error) {
var pin *string
err := s.pool.QueryRow(ctx, `SELECT pin_value FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID).Scan(&pin)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrViewerNotFound
}
if err != nil {
return "", err
}
if pin == nil {
return "", nil
}
return *pin, nil
}
// CheckViewerPIN applies the failed-attempt budget. Ten failures are not
// permanent revocation; they are a short server-side pause which survives a reinstall.
func (s *Store) CheckViewerPIN(ctx context.Context, embyUserID, viewerID string, valid func([]byte) bool) (bool, error) {
var hash []byte
var failures int
var lockedUntil *time.Time
err := s.pool.QueryRow(ctx, `SELECT pin_hash, pin_failed_attempts, pin_locked_until
FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID).Scan(&hash, &failures, &lockedUntil)
if errors.Is(err, pgx.ErrNoRows) {
return false, ErrViewerNotFound
}
if err != nil {
return false, fmt.Errorf("store: read viewer pin: %w", err)
}
if len(hash) == 0 {
return true, nil
}
if lockedUntil != nil && time.Now().Before(*lockedUntil) {
return false, ErrPINLocked
}
if valid(hash) {
_, err = s.pool.Exec(ctx, `UPDATE viewers SET pin_failed_attempts = 0, pin_locked_until = NULL WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID)
return err == nil, err
}
failures++
if failures >= 5 {
lockedUntil = func() *time.Time { t := time.Now().Add(time.Duration(failures-4) * 15 * time.Second); return &t }()
}
_, err = s.pool.Exec(ctx, `UPDATE viewers SET pin_failed_attempts = $3, pin_locked_until = $4 WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, failures, lockedUntil)
if err != nil {
return false, err
}
return false, ErrPINInvalid
}
// ensureMainViewer records the account's own viewer if it has none.
//
// The insert is ON CONFLICT DO NOTHING on the primary key, so two televisions signing in