Files
silo-server/internal/api/handlers/profiles.go
9aab2ead57 feat(settings): add user-facing device settings (#527)
* feat(settings): let users manage device settings across their devices

Adds the server half of the user-facing device settings screen: a viewer can
see the devices they watch on and change settings for any of them from
whichever device they are holding, and the household parent can do the same for
everyone on the account.

No schema change. user_devices and user_setting_values are already keyed
(user_id, profile_id, device_id), and both list queries are already
account-wide, so this is authorization plus routes.

Two identity widenings on the canonical settings API, each behind a guard:

- A caller may name a device_id other than the request's own. Authorized
  against user_devices for that profile, which is why DeviceExists lands
  first: completeIdentity validated an identity's shape but never that the
  device belonged to the caller, safe only while the id came from the header.
- A household parent may name a profile_id other than their own. Guarded by
  canManageHousehold, extracted from ProfileHandler so profile management and
  settings management cannot drift apart. Existence resolves through the
  caller's own store, so a foreign profile is 404 and the cross-account
  boundary holds for free.

Both default to today's behavior when the parameter is absent, so existing
clients need no change.

New self-service routes: GET /devices, DELETE /devices/{id}, and
DELETE /devices/{id}/settings. The list filters to the calling profile in the
handler — ListDevices is account-wide by construction in both backends (no
WHERE at all in the per-user SQLite), so a passthrough would have shown every
household member's devices to everyone. ?scope=household is opt-in and guarded.

Also fixes a bug the widening exposed: registerWritingDevice fired on every
device write, so writing to another device — or on another profile's behalf —
would have registered the actor's browser under the target, inventing a device
nobody holds.

Cross-profile and admin mutations are audited. The record carries identity
only, never the value, for the same reason user_settings.changed does: admins
receive other accounts' events. Ordinary self-service writes are not audited —
a trail that records everything answers nothing.

Part of #215

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): add the user-facing device settings screen

Adds "Your devices" under Settings: a searchable device list and an editable
detail pane, so someone can fix how Silo behaves on any device they watch on
without borrowing that device. The household parent gets an "Everyone" switch
covering every profile on the account.

Layout is master-detail, and holds at eleven devices: fixed-height rows
carrying a name, when it was last used, and the one number that matters — how
many settings differ there. A device with nothing changed shows a dash rather
than a zero, so "which one did I change?" is answerable by scanning. Rows group
by recency, or by person in the household view.

Settings are grouped by what they affect — Picture, Sound, Subtitles,
Episodes — rather than in manifest order, and no raw key is ever shown: labels,
descriptions, controls, bounds and options all come from the contract. A test
asserts every device-scoped key lands in exactly one group or is deliberately
hidden, so a key added to the manifest cannot silently vanish from the screen.

Values round-trip as typed JSON rather than through strings, unlike the admin
console: a slider re-parsed from text is a hazard on a screen a viewer drives.

Policy caps are explained rather than hidden. A capped setting renders only the
permitted options and says which value the household limit displaced; a locked
one says so instead of presenting a disabled control with no reason.

Acting for someone else is stated, never implied — a persistent banner, and
reset actions that name the person ("Use Robin's setting"). The household view
also states what it does not show: this is how Silo is set up per device, not
what anyone watched.

Two fixes the work turned up:

- effectiveSettingsQueryKey was namespaced by active profile only, so reading
  another device's values would have collided with the current device's cache
  entry and served one device's settings as another's.
- The settings shell caps content at max-w-3xl, which is right for a single
  column of rows and squeezes a two-pane page. Pages that manage their own
  layout now opt out.

Part of #215

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): filter the household device list by profile

Adds profile chips above the device list in the household view: Everyone, then
one per person, each with a device count. Picking a person narrows the list to
their devices; picking the active chip again clears it.

The chips only appear in the household view, where more than one profile is on
screen. A viewer looking at their own devices has exactly one profile, so a
filter with a single option would be chrome that explains nothing.

Three details the interaction needs to be honest:

- Counts come from the unfiltered list, so a chip keeps saying how many devices
  it would reveal instead of collapsing to zero once another chip is active.
- Grouping falls back to recency once a person is chosen, because a person
  heading would only repeat the chip above it.
- The detail pane follows the filter. Leaving someone else's device open while
  the list shows another person would make the list and the pane disagree about
  whose settings are being edited — the one thing this screen cannot be vague
  about. Leaving the household view clears the filter for the same reason.

The chip's count sits in its own element, so its accessible name is set
explicitly: without it a screen reader announces "Everyone3".

Part of #215

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(settings): lead the profile filter with the viewer's own chip

Seeding a realistic eight-profile household made the ordering problem obvious:
chips were in device-arrival order, which put the person actually using the
screen last. Their own profile now comes first and the rest sort by name, so a
chip stays where it was last seen rather than moving as devices are used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(settings): make the device screen work on a phone

The screen was built two-pane and stacked those panes vertically on narrow
viewports, which is the wrong shape for a phone: the device list ran past a
thousand pixels before the first setting, so reaching "turn HDR off" meant
scrolling through every other device to get there. The whole page measured 4726
CSS pixels — 5.6 screens — for four devices.

Below xl the list and the settings are now two screens rather than two panes.
Picking a device swaps to it and a back control returns; the page header and
scope switch belong to the list screen, and the detail screen's own header says
which device it is about. The list page is 1050px, and scroll position resets on
each swap so a tap does not land mid-settings.

Touch targets were 32-36px throughout. Rows, chips, the scope switch, the reset
link and the header actions now clear 44px on a phone and keep their compact
desktop sizing from xl. Device rows carry a chevron below xl, because there they
navigate rather than select in place. The device search input goes to 16px on
mobile — iOS Safari zooms the viewport for anything smaller and does not zoom
back out.

Profile chips wrapped to three lines at eight profiles and pushed the list off
screen; they scroll horizontally on one line instead, the same trade the
settings shell's own mobile tab bar makes. Switches now sit beside their labels
rather than below, saving a row on each of ~18 toggles, while selects and
sliders still take the full width they need.

Two fixes the pass turned up, neither mobile-specific:

- playback.max_bitrate_kbps is declared as an integer range with a select
  control and no members, so it rendered as a dropdown with one blank entry —
  unusable, and silent about the value it was storing. It now offers real
  bandwidth choices bounded by the definition's own range, and keeps a
  non-preset stored value selectable.
- Select triggers had no accessible name, announcing as bare comboboxes.

Forget is destructive and rare, so it no longer sits as a full-width sibling of
the common action.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(settings): keep the device list usable at hundreds of devices

A real account carries 260 devices; the test fixtures had 14. Every browser
profile, private window, reinstall and test build registers a device identity
and nothing prunes them, so the list grew without limit — 13,681 CSS pixels,
sixteen screens, and the settings themselves never came into view. That is the
same failure the mobile pass just fixed, at a scale the fixtures never showed.

Three changes:

- The list is a bounded scroll area rather than an unbounded column. The page
  is now ~1,300px whatever the device count, and section headings stick while
  scrolling so the recency or person grouping stays legible.
- Devices nobody has used for 90 days that carry no settings of their own
  collapse behind "Show N unused devices". Over half of the real fleet is that:
  one-off sessions that never changed anything. The current device and anything
  with settings always stay visible, however old.
- Search spans everything including the hidden tail, because searching means
  looking for something specific and hiding a device from its own name would
  read as the device having vanished.

The 90-day threshold matches the settings contract's own rule for removing
empty device records, which is specified but not yet implemented server-side —
until it is, this keeps the screen usable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(settings): offer the whole bandwidth range the contract allows

The bandwidth cap stopped at 40 Mbps because the ladder behind it was a
hardcoded list I wrote, not anything the contract said. The definition allows
up to 200,000 kbps, and remuxed 4K HDR or an untouched Blu-ray rip needs well
past 40 — so the picker was silently capping people below what their own server
could already send them.

The ladder now runs to the definition's own ceiling. Its low end mirrors the
in-player quality switcher, so a cap chosen here lines up with what the player
offers mid-playback, and entries outside a definition's declared range are
filtered out as before.

Also stops duplicating the label format a third time: the player's
formatQualityBitrate is now exported and reused, since both surfaces pick from
the same ladder and should not disagree about how to spell a number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(settings): use the app's overlay scrollbar in the device list

The bounded device list kept the browser's default scrollbar, which reads as a
heavy grey slab against a rounded dark panel. The app already has
.overlay-scroll for exactly this — a thin, low-contrast thumb over a
transparent track — and it now sits in the panel's gutter rather than flush
against the rounded edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(settings): stop effective-values from stalling on live catalog language scans

GET /settings/values/effective computed suggested language values with
three sequential full-catalog scans on every request. On a 439k-item
deployment that took ~25s, dominated by the subtitle listing whose UNION
deduplicated ~5M unnested track rows.

- Deduplicate each subtitle arm before merging (UNION ALL of two
  DISTINCT arms instead of UNION across all rows) and bound the result
  with the facet LIMIT: 22.8s -> ~8.5s of per-arm work on that catalog.
- Cache the observed lists per (list kind, access scope) for 15 minutes
  and collapse concurrent misses with singleflight.
- Run the three lookups concurrently, and cap a cold-cache wait at 2s:
  the response ships with the contract floor while the detached scan
  finishes and fills the cache for the next request.

Part of the device settings screen work; the regression itself shipped
in #526.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(settings): replace track-language catalog scans with picker free entry

Deployment-observed suggestions now decorate catalog.metadata_language
only — original_language is one indexed DISTINCT scan (~0.2s on a 439k
item catalog) and feeds the metadata-exceptions panel, where observed
data is load-bearing. The audio and subtitle track listings (up to tens
of seconds of media-file walking) are no longer queried for settings at
all, which also removes the TTL cache and singleflight added to manage
them.

Those pickers keep the contract's authored floor and gain an explicit
escape hatch instead: a shared LanguageSelect with an "Other…" entry
that accepts a BCP 47 tag, previews the resolved language name, and
refuses invalid tags. The settings are open language_tag values, so a
typed tag needs no server change, and a stored off-floor value already
renders through the current-value merge. The device screen hides the
free entry when policy pins permitted_values.

The browse facet queries keep the subtitle UNION ALL rewrite from the
previous commit; catalog browse still lists observed track languages.

suggested_values on playback.audio_language and
playback.subtitle_language now returns only the contract floor plus the
stored value — Android and Apple pickers need the same free-entry
affordance as follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:00:15 -04:00

824 lines
30 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/Silo-Server/silo-server/internal/access"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ProfileHandler handles profile CRUD endpoints.
type ProfileHandler struct {
storeProvider userstore.UserStoreProvider
SessionsReader playbackSessionsReader
UserRepo interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
ProfileTokens *access.ProfileTokenService
AvatarStore profileAvatarStore
AvatarTTL time.Duration
// DeviceLibraryPurger removes a deleted profile's device rows (and, via
// cascade, its managed downloads and subscriptions). Profiles may live
// outside Postgres, so no FK cascade covers these shared tables.
DeviceLibraryPurger interface {
PurgeProfileDevices(ctx context.Context, userID int, profileID string) error
}
// EventsHub, when set, receives a user_settings.changed event for every
// canonical setting row a profile mutation syncs (see
// profiles_settings_sync.go). Nil (as in tests) simply skips publishing.
EventsHub *evt.Hub
}
// NewProfileHandler creates a new ProfileHandler.
func NewProfileHandler(provider userstore.UserStoreProvider) *ProfileHandler {
return &ProfileHandler{
storeProvider: provider,
AvatarTTL: 15 * time.Minute,
}
}
// --- Request/Response types ---
type createProfileRequest struct {
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
PIN string `json:"pin,omitempty"`
IsChild bool `json:"is_child"`
MaxContentRating string `json:"max_content_rating,omitempty"`
QualityPreference string `json:"quality_preference,omitempty"`
Language string `json:"language,omitempty"`
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage string `json:"subtitle_language,omitempty"`
SubtitleMode string `json:"subtitle_mode,omitempty"`
AutoSkipIntro bool `json:"auto_skip_intro"`
AutoSkipCredits bool `json:"auto_skip_credits"`
AutoSkipRecap bool `json:"auto_skip_recap"`
AutoPlayNextPreview bool `json:"auto_play_next_preview"`
ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"`
LibraryRestrictionsEnabled bool `json:"library_restrictions_enabled"`
AllowedLibraryIDs []int `json:"allowed_library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
}
type updateProfileRequest struct {
Name *string `json:"name,omitempty"`
Avatar *string `json:"avatar,omitempty"`
PIN *string `json:"pin,omitempty"`
IsChild *bool `json:"is_child,omitempty"`
MaxContentRating *string `json:"max_content_rating,omitempty"`
QualityPreference *string `json:"quality_preference,omitempty"`
Language *string `json:"language,omitempty"`
PreferredMetadataLanguage *string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage *string `json:"subtitle_language,omitempty"`
SubtitleMode *string `json:"subtitle_mode,omitempty"`
AutoSkipIntro *bool `json:"auto_skip_intro,omitempty"`
AutoSkipCredits *bool `json:"auto_skip_credits,omitempty"`
AutoSkipRecap *bool `json:"auto_skip_recap,omitempty"`
AutoPlayNextPreview *bool `json:"auto_play_next_preview,omitempty"`
ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"`
LibraryRestrictionsEnabled *bool `json:"library_restrictions_enabled,omitempty"`
AllowedLibraryIDs *[]int `json:"allowed_library_ids,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
}
type verifyPINRequest struct {
PIN string `json:"pin"`
}
type profileResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
AvatarSource string `json:"avatar_source,omitempty"`
HasPIN bool `json:"has_pin"`
IsChild bool `json:"is_child"`
IsPrimary bool `json:"is_primary"`
MaxContentRating string `json:"max_content_rating,omitempty"`
QualityPreference string `json:"quality_preference,omitempty"`
Language string `json:"language,omitempty"`
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage string `json:"subtitle_language,omitempty"`
SubtitleMode string `json:"subtitle_mode,omitempty"`
AutoSkipIntro bool `json:"auto_skip_intro"`
AutoSkipCredits bool `json:"auto_skip_credits"`
AutoSkipRecap bool `json:"auto_skip_recap"`
AutoPlayNextPreview bool `json:"auto_play_next_preview"`
ShowForcedSubtitles bool `json:"show_forced_subtitles"`
LibraryRestrictionsEnabled bool `json:"library_restrictions_enabled"`
AllowedLibraryIDs []int `json:"allowed_library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type profileListResponse struct {
Profiles []profileResponse `json:"profiles"`
AvatarUploadEnabled bool `json:"avatar_upload_enabled"`
}
type verifyPINResponse struct {
Valid bool `json:"valid"`
ProfileToken string `json:"profile_token,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// canManageHouseholdProfiles reports whether the caller may create/update/delete
// profiles belonging to their user.
//
// The rule lives in household.go so the settings routes can apply the same one:
// a household parent who may edit a child's profile may also edit that child's
// device settings, and two definitions of "is this the household parent" would
// eventually disagree.
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
return canManageHousehold(r, store, h.userLookupOrNil(), h.ProfileTokens)
}
// userLookupOrNil returns UserRepo as the narrow interface the household check
// wants, preserving nil-ness: a typed nil in a non-nil interface would defeat
// the fail-closed check there.
func (h *ProfileHandler) userLookupOrNil() userLookup {
if h.UserRepo == nil {
return nil
}
return h.UserRepo
}
func writeProfileManagementPermissionError(w http.ResponseWriter, err error) {
if errors.Is(err, access.ErrProfileUnverified) {
writeError(w, http.StatusForbidden, "forbidden", "Profile management requires verifying the primary profile PIN")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check profile permissions")
}
// profileNameConflicts reports whether a profile other than excludeID already
// uses name within this account's store, comparing the trimmed forms
// case-insensitively so "Laura" and " laura " count as the same household
// member. Scoping is per account by construction: callers pass the profile
// list of a single user's store, so another account's profiles can never
// conflict.
//
// This is a check-then-write guard with no store-level uniqueness constraint
// (the userstore's dual Postgres/SQLite backends carry no unique index on
// name), so two concurrent requests can both pass and insert duplicates —
// the same window the profile_limit_reached check accepts. Good enough for
// interactive profile management; a functional unique index is the fix if
// that ever stops being true.
func profileNameConflicts(profiles []userstore.Profile, name, excludeID string) bool {
trimmed := strings.TrimSpace(name)
for _, p := range profiles {
if p.ID == excludeID {
continue
}
if strings.EqualFold(strings.TrimSpace(p.Name), trimmed) {
return true
}
}
return false
}
// isAllowedSelfServiceProfileUpdate reports whether a non-admin update request
// only touches fields the user is allowed to change on their own profiles.
// Admin-only fields (access policy: library restrictions, content rating,
// playback-quality cap, child-profile flag) must be rejected for non-admins.
func isAllowedSelfServiceProfileUpdate(req updateProfileRequest) bool {
return req.IsChild == nil &&
req.MaxContentRating == nil &&
req.LibraryRestrictionsEnabled == nil &&
req.AllowedLibraryIDs == nil &&
req.MaxPlaybackQuality == nil
}
// --- Handler methods ---
// HandleListProfiles handles GET /profiles.
func (h *ProfileHandler) HandleListProfiles(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
profiles, err := store.ListProfiles(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
resp := profileListResponse{
Profiles: h.toProfileResponses(r.Context(), store, profiles),
AvatarUploadEnabled: h.AvatarStore != nil,
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreateProfile handles POST /profiles.
func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
var req createProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile name is required")
return
}
avatarRef, err := normalizePresetAvatarReference(req.Avatar)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
// Planned before anything is written: a preference value the canonical
// store would refuse must fail the request while it is still a no-op.
settingsSync, err := planCreateProfileSettingsSync(req)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
existingProfiles, err := store.ListProfiles(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
// The very first profile on a user can be bootstrapped without
// primary/admin privileges (it becomes the primary); everything after
// requires either the server admin role or the caller's active profile
// being primary.
isBootstrap := len(existingProfiles) == 0
if !isBootstrap {
allowed, err := h.canManageHouseholdProfiles(r, store)
if err != nil {
writeProfileManagementPermissionError(w, err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "Profile management requires the primary profile or admin access")
return
}
}
// Access-policy fields only make sense when set by a manager on a managed
// profile. On bootstrap the caller is becoming primary themselves, so non-
// admin bootstrap creations must leave those fields at their defaults.
if isBootstrap && !apimw.IsAdmin(r.Context()) &&
(req.IsChild || req.MaxContentRating != "" ||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
req.MaxPlaybackQuality != "") {
writeError(
w,
http.StatusForbidden,
"forbidden",
"Profile access settings require the primary profile or admin access",
)
return
}
if h.UserRepo != nil {
user, err := h.UserRepo.GetByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user")
return
}
if user != nil && user.MaxProfiles >= 1 && len(existingProfiles) >= user.MaxProfiles {
writeError(
w,
http.StatusConflict,
"profile_limit_reached",
fmt.Sprintf("This account has reached its profile limit (%d)", user.MaxProfiles),
)
return
}
}
if profileNameConflicts(existingProfiles, req.Name, "") {
writeError(
w,
http.StatusConflict,
"name_conflict",
"A profile with this name already exists",
)
return
}
showForcedSubtitles := true
if req.ShowForcedSubtitles != nil {
showForcedSubtitles = *req.ShowForcedSubtitles
}
profileID := uuid.New().String()
profile := userstore.Profile{
ID: profileID,
// Store the trimmed form the conflict check compared, so " Laura "
// doesn't persist with stray whitespace.
Name: strings.TrimSpace(req.Name),
Avatar: avatarRef,
IsChild: req.IsChild,
MaxContentRating: req.MaxContentRating,
QualityPreference: req.QualityPreference,
Language: req.Language,
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
SubtitleLanguage: req.SubtitleLanguage,
SubtitleMode: req.SubtitleMode,
AutoSkipIntro: req.AutoSkipIntro,
AutoSkipCredits: req.AutoSkipCredits,
AutoSkipRecap: req.AutoSkipRecap,
AutoPlayNextPreview: req.AutoPlayNextPreview,
ShowForcedSubtitles: showForcedSubtitles,
LibraryRestrictionsEnabled: req.LibraryRestrictionsEnabled,
AllowedLibraryIDs: req.AllowedLibraryIDs,
MaxPlaybackQuality: maxPlaybackQuality,
}
if err := h.createProfileWithSettingsSync(r.Context(), store, userID, profile, settingsSync); err != nil {
slog.ErrorContext(r.Context(), "profile create failed to sync canonical settings",
"component", "api", "user_id", userID, "profile_id", profileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store profile preferences")
return
}
// Fetch the created profile directly by ID (no race condition).
createdPtr, err := store.GetProfile(r.Context(), profileID)
if err != nil || createdPtr == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve created profile")
return
}
created := *createdPtr
// If PIN was provided, update the profile to set it.
if req.PIN != "" {
if err := store.UpdateProfile(r.Context(), created.ID, userstore.UpdateProfileInput{
PIN: &req.PIN,
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set profile PIN")
return
}
// Re-read the profile to get the updated state.
p, err := store.GetProfile(r.Context(), created.ID)
if err != nil || p == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve profile after PIN set")
return
}
created = *p
}
if req.ShowForcedSubtitles != nil && !*req.ShowForcedSubtitles {
if err := store.UpdateProfile(r.Context(), created.ID, userstore.UpdateProfileInput{
ShowForcedSubtitles: req.ShowForcedSubtitles,
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set forced subtitle preference")
return
}
p, err := store.GetProfile(r.Context(), created.ID)
if err != nil || p == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve profile after forced subtitle update")
return
}
created = *p
}
writeJSON(w, http.StatusCreated, h.toProfileResponse(r.Context(), store, created))
}
// HandleUpdateProfile handles PUT /profiles/{id}.
func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
profileID := chi.URLParam(r, "id")
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required")
return
}
var req updateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
var avatarRef *string
if req.Avatar != nil {
normalized, err := normalizePresetAvatarReference(*req.Avatar)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
avatarRef = &normalized
}
var maxPlaybackQuality *string
if req.MaxPlaybackQuality != nil {
normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
maxPlaybackQuality = &normalized
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
currentProfile, err := store.GetProfile(r.Context(), profileID)
if err != nil || currentProfile == nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
return
}
canManage, err := h.canManageHouseholdProfiles(r, store)
if err != nil {
writeProfileManagementPermissionError(w, err)
return
}
if !canManage {
// Non-managers may only update their own active profile and only a
// narrow set of playback preferences.
activeProfileID := apimw.GetProfileID(r.Context())
if activeProfileID == "" {
activeProfileID = r.Header.Get("X-Profile-Id")
}
if activeProfileID == "" || activeProfileID != profileID {
writeError(
w,
http.StatusForbidden,
"forbidden",
"You can only update the active profile's playback preferences",
)
return
}
if !isAllowedSelfServiceProfileUpdate(req) {
writeError(
w,
http.StatusForbidden,
"forbidden",
"Profile access settings require the primary profile or admin access",
)
return
}
}
if req.Name != nil {
// Normalize to the trimmed form up front: the conflict check compares
// it and the store persists it, so " Laura " never lands verbatim.
trimmedName := strings.TrimSpace(*req.Name)
if trimmedName == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile name is required")
return
}
req.Name = &trimmedName
existingProfiles, err := store.ListProfiles(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
if profileNameConflicts(existingProfiles, *req.Name, profileID) {
writeError(
w,
http.StatusConflict,
"name_conflict",
"A profile with this name already exists",
)
return
}
}
// Planned before the transaction so an invalid preference fails while the
// request is still a no-op.
settingsSync, err := planUpdateProfileSettingsSync(req)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
input := userstore.UpdateProfileInput{
Name: req.Name,
Avatar: avatarRef,
PIN: req.PIN,
IsChild: req.IsChild,
MaxContentRating: req.MaxContentRating,
QualityPreference: req.QualityPreference,
Language: req.Language,
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
SubtitleLanguage: req.SubtitleLanguage,
SubtitleMode: req.SubtitleMode,
AutoSkipIntro: req.AutoSkipIntro,
AutoSkipCredits: req.AutoSkipCredits,
AutoSkipRecap: req.AutoSkipRecap,
AutoPlayNextPreview: req.AutoPlayNextPreview,
ShowForcedSubtitles: req.ShowForcedSubtitles,
LibraryRestrictionsEnabled: req.LibraryRestrictionsEnabled,
AllowedLibraryIDs: req.AllowedLibraryIDs,
MaxPlaybackQuality: maxPlaybackQuality,
}
// The profile columns and their canonical projections commit together. A
// failure cannot leave a 500 response whose legacy values look saved while
// canonical readers continue serving the previous preference.
if err := h.applyProfileUpdateSettingsSync(
r.Context(), store, userID, profileID, input, settingsSync,
); err != nil {
slog.ErrorContext(r.Context(), "profile update failed to sync canonical settings",
"component", "api", "user_id", userID, "profile_id", profileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store profile preferences")
return
}
if currentProfile.Avatar != "" && avatarRef != nil && avatarRefReplacesUpload(currentProfile.Avatar, *avatarRef) {
if cleanupErr := deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID); cleanupErr != nil {
slog.WarnContext(r.Context(), "profile avatar cleanup failed after update", "component", "api", "user_id", userID, "profile_id", profileID, "error", cleanupErr)
}
}
// Re-read the profile to return the updated state.
profile, err := store.GetProfile(r.Context(), profileID)
if err != nil || profile == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve updated profile")
return
}
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), store, *profile))
}
// HandleDeleteProfile handles DELETE /profiles/{id}.
func (h *ProfileHandler) HandleDeleteProfile(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
profileID := chi.URLParam(r, "id")
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
allowed, err := h.canManageHouseholdProfiles(r, store)
if err != nil {
writeProfileManagementPermissionError(w, err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "Profile management requires the primary profile or admin access")
return
}
profile, err := store.GetProfile(r.Context(), profileID)
if err != nil || profile == nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
return
}
if profile.IsPrimary {
writeError(
w,
http.StatusConflict,
"primary_profile_protected",
"The primary profile cannot be deleted. Delete the user account instead.",
)
return
}
if err := store.DeleteProfile(r.Context(), profileID); err != nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
return
}
if isUploadedAvatarRef(profile.Avatar) {
if cleanupErr := deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID); cleanupErr != nil {
slog.WarnContext(r.Context(), "profile avatar cleanup failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", cleanupErr)
}
}
if h.DeviceLibraryPurger != nil {
purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second)
defer cancel()
if purgeErr := h.DeviceLibraryPurger.PurgeProfileDevices(purgeCtx, userID, profileID); purgeErr != nil {
slog.WarnContext(r.Context(), "profile device-library purge failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", purgeErr)
}
}
w.WriteHeader(http.StatusNoContent)
}
// HandleVerifyPIN handles POST /profiles/{id}/verify-pin.
func (h *ProfileHandler) HandleVerifyPIN(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
profileID := chi.URLParam(r, "id")
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required")
return
}
var req verifyPINRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.PIN == "" {
writeError(w, http.StatusBadRequest, "bad_request", "PIN is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
valid, err := store.VerifyPIN(r.Context(), profileID, req.PIN)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found or has no PIN")
return
}
if !valid || h.UserRepo == nil || h.ProfileTokens == nil {
writeJSON(w, http.StatusOK, verifyPINResponse{Valid: valid})
return
}
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
user, err := h.UserRepo.GetByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user policy")
return
}
token, expiresAt, err := h.ProfileTokens.Mint(access.ProfileTokenClaims{
UserID: userID,
SessionID: claims.SessionID,
ProfileID: profileID,
PolicyRevision: user.AccessPolicyRevision,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue profile token")
return
}
resp := verifyPINResponse{
Valid: true,
ProfileToken: token,
}
if !expiresAt.IsZero() {
resp.ExpiresAt = expiresAt.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, resp)
}
// --- Helpers ---
// toProfileResponse serializes one profile, resolving its preference block on
// its own. Callers serializing several profiles must use toProfileResponses
// instead so the whole list costs one store read.
func (h *ProfileHandler) toProfileResponse(
ctx context.Context, store userstore.UserStore, p userstore.Profile,
) profileResponse {
prefs := resolveProfilePreferences(ctx, store, []string{p.ID})
return h.profileResponseWith(ctx, p, prefs[p.ID])
}
// toProfileResponses serializes a whole household, resolving every profile's
// preference block in one store read rather than one per profile.
func (h *ProfileHandler) toProfileResponses(
ctx context.Context, store userstore.UserStore, profiles []userstore.Profile,
) []profileResponse {
ids := make([]string, 0, len(profiles))
for _, p := range profiles {
ids = append(ids, p.ID)
}
prefs := resolveProfilePreferences(ctx, store, ids)
out := make([]profileResponse, 0, len(profiles))
for _, p := range profiles {
out = append(out, h.profileResponseWith(ctx, p, prefs[p.ID]))
}
return out
}
// profileResponseWith builds the DTO from a profile row and its already
// resolved preferences.
//
// The preference fields come from prefs rather than from p: those five are
// canonical now, and the legacy columns behind them are written but no longer
// read (see profiles_settings_sync.go). Everything else is still column-backed.
func (h *ProfileHandler) profileResponseWith(
ctx context.Context, p userstore.Profile, prefs profilePreferences,
) profileResponse {
avatarSource, avatarURL := resolveProfileAvatar(ctx, h.AvatarStore, h.AvatarTTL, p.Avatar)
return profileResponse{
ID: p.ID,
Name: p.Name,
Avatar: p.Avatar,
AvatarURL: avatarURL,
AvatarSource: avatarSource,
HasPIN: p.PINHash != "",
IsChild: p.IsChild,
IsPrimary: p.IsPrimary,
MaxContentRating: p.MaxContentRating,
QualityPreference: p.QualityPreference,
Language: prefs.AudioLanguage,
PreferredMetadataLanguage: prefs.MetadataLanguage,
SubtitleLanguage: prefs.SubtitleLanguage,
SubtitleMode: prefs.SubtitleMode,
AutoSkipIntro: p.AutoSkipIntro,
AutoSkipCredits: p.AutoSkipCredits,
AutoSkipRecap: p.AutoSkipRecap,
AutoPlayNextPreview: p.AutoPlayNextPreview,
ShowForcedSubtitles: prefs.ShowForcedSubtitles,
LibraryRestrictionsEnabled: p.LibraryRestrictionsEnabled,
AllowedLibraryIDs: append([]int(nil), p.AllowedLibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(p.MaxPlaybackQuality),
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
// HandleListHouseholdSessions handles GET /profiles/household/sessions.
func (h *ProfileHandler) HandleListHouseholdSessions(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
allowed, err := h.canManageHouseholdProfiles(r, store)
if err != nil {
writeProfileManagementPermissionError(w, err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "Profile management requires the primary profile or admin access")
return
}
if h.SessionsReader == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Playback sessions are not configured")
return
}
sessions, err := h.SessionsReader.Load(r.Context(), r, PlaybackSessionsQuery{UserID: userID})
if err != nil {
slog.ErrorContext(r.Context(), "failed to list household playback sessions", "component", "api", "user_id", userID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list playback sessions")
return
}
writeJSON(w, http.StatusOK, sessions)
}