0.3.22 - PINs
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user