Files
silo-server/internal/api/handlers/settings_values.go
T
QuickandClaude Fable 5 2d74be984f feat(settings): publish user_settings change events
Add a user_settings realtime channel so clients learn when a setting
changed on another device without polling. The channel is modeled on
user_state: non-admin subscribable, per-user addressed envelopes, null
snapshot.

SettingValuesHandler gains an EventsHub and publishes
user_settings.changed after every successful PUT and DELETE on
/settings/values/{key}. The payload carries only key, scope and
profile_id — never the value. Admins receive every user's user-scoped
events, so a value in the payload would leak private settings to
admins; interested clients re-fetch over the scoped REST API instead.
The payload is always non-empty because an empty Data falls back to a
null snapshot in the hub. A nil hub (tests) skips publishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 02:36:38 +00:00

592 lines
20 KiB
Go

package handlers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"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/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// SettingValuesHandler serves the canonical settings API: the contract itself,
// explicit values at one scope, batched effective values, and idempotent
// mutations.
//
// It replaces the string-only registry in settings.go. The differences that
// matter: values are typed JSON rather than strings, a value carries the scope
// it lives at rather than being one of two hardcoded scopes, and an unknown key
// is refused rather than stored in an open extension bag.
type SettingValuesHandler struct {
storeProvider userstore.UserStoreProvider
contract *settingscontract.Manifest
resolver *settingsresolve.Resolver
// EventsHub, when set, receives a user_settings.changed event after every
// successful write or delete. Nil (as in tests) simply skips publishing.
EventsHub *evt.Hub
}
// NewSettingValuesHandler builds the handler over the embedded contract.
func NewSettingValuesHandler(
provider userstore.UserStoreProvider,
contract *settingscontract.Manifest,
) *SettingValuesHandler {
return &SettingValuesHandler{
storeProvider: provider,
contract: contract,
resolver: settingsresolve.New(contract),
}
}
// mutationIDHeader carries the client's idempotency key.
const mutationIDHeader = "X-Silo-Mutation-Id"
// fieldRevision is the response field carrying the contract revision. Clients
// filter definitions, scopes and enum members against it, so every response
// that could be acted on names the revision it was computed at.
const fieldRevision = "revision"
// settingValueResponse is one explicit stored value.
type settingValueResponse struct {
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
Value json.RawMessage `json:"value"`
Revision int64 `json:"revision"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// effectiveSettingValueResponse is one resolved value plus where it came from.
type effectiveSettingValueResponse struct {
Key string `json:"key"`
Value json.RawMessage `json:"value"`
Source string `json:"source"`
// StoredValue and Constrained are present only when policy narrowed the
// answer. The authored value is reported rather than discarded so a client
// can say "your choice is capped" instead of silently showing the cap.
StoredValue json.RawMessage `json:"stored_value,omitempty"`
Constrained bool `json:"constrained,omitempty"`
ConstraintKind string `json:"constraint_kind,omitempty"`
// Scope locates the row the value came from, so a client can offer a reset
// against exactly that scope. Empty for a contract default.
Scope string `json:"scope,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
}
// HandleGetContract serves the public manifest.
//
// ETag-gated: clients vendor a pinned copy and generate bindings from it, so
// the common request is a conditional GET that answers "still the same
// contract?" without transferring it.
func (h *SettingValuesHandler) HandleGetContract(w http.ResponseWriter, r *http.Request) {
etag, err := settingscontract.PublicETag()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read the settings contract")
return
}
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if match := r.Header.Get("If-None-Match"); match != "" && etagMatches(match, etag) {
w.WriteHeader(http.StatusNotModified)
return
}
body, err := settingscontract.PublicBytes()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read the settings contract")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// HandleGetCapabilities reports what this server supports, for feature
// detection rather than version sniffing.
func (h *SettingValuesHandler) HandleGetCapabilities(w http.ResponseWriter, r *http.Request) {
etag, err := settingscontract.PublicETag()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read the settings contract")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"api_version": h.contract.APIVersion,
fieldRevision: h.contract.Revision,
"contract_etag": etag,
"definition_count": len(h.contract.Definitions),
"scopes": []string{
string(settingscontract.ScopeAccount),
string(settingscontract.ScopeProfile),
string(settingscontract.ScopeProfileDevice),
string(settingscontract.ScopeProfileLibrary),
string(settingscontract.ScopeProfileSeries),
},
"supports_batched_effective": true,
"supports_idempotent_writes": true,
})
}
// HandleGetValue returns the explicit value at one scope, or 404 when the user
// has none there.
//
// Deliberately not a resolution: this answers "did I set this here", which is
// what a reset affordance needs. Use the effective endpoint for "what applies".
func (h *SettingValuesHandler) HandleGetValue(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
identity, ok := h.identityFromRequest(w, r)
if !ok {
return
}
value, err := store.GetSettingValue(r.Context(), identity)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read the setting")
return
}
if value == nil {
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
return
}
writeJSON(w, http.StatusOK, settingValueToResponse(*value))
}
// HandleSetValue writes an explicit value at one scope.
//
// A value that exceeds a policy restriction is stored, not rejected: the
// restriction filters what a preference does at resolution time, and destroying
// the preference would mean a capped 4K choice never takes effect when the cap
// lifts.
func (h *SettingValuesHandler) HandleSetValue(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
identity, ok := h.identityFromRequest(w, r)
if !ok {
return
}
def, ok := h.definitionFor(w, identity.Key)
if !ok {
return
}
var body struct {
Value json.RawMessage `json:"value"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Body must be {\"value\": …}")
return
}
if len(body.Value) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "value is required")
return
}
normalized, err := def.ValueSchema.NormalizeValue(body.Value, settingscontract.ObjectSchemas())
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_value", err.Error())
return
}
// Idempotency: a client that retries a write after a dropped response must
// not double-apply it, and must be able to tell "already done" from "that
// id means something else".
mutationID := strings.TrimSpace(r.Header.Get(mutationIDHeader))
if mutationID != "" {
requestHash := hashMutationRequest(identity, normalized)
prior, err := store.GetSettingMutation(r.Context(), mutationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check the mutation id")
return
}
if prior != nil {
if prior.RequestHash != requestHash {
writeError(w, http.StatusConflict, "mutation_id_conflict",
"This mutation id was used for a different write")
return
}
w.Header().Set("X-Silo-Idempotent-Replay", "true")
writeRawJSON(w, http.StatusOK, prior.Result)
return
}
defer h.recordMutation(r, store, mutationID, requestHash, identity, normalized)
}
stored, err := store.UpsertSettingValue(r.Context(), identity, normalized)
if err != nil {
if errors.Is(err, userstore.ErrInvalidSettingIdentity) ||
errors.Is(err, userstore.ErrInvalidSettingValue) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return
}
publishUserSettingsEvent(r.Context(), h.EventsHub,
apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope))
writeJSON(w, http.StatusOK, settingValueToResponse(*stored))
}
// HandleDeleteValue removes the explicit value at one scope, which is how a
// client says "stop overriding here and inherit again".
func (h *SettingValuesHandler) HandleDeleteValue(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
identity, ok := h.identityFromRequest(w, r)
if !ok {
return
}
removed, err := store.DeleteSettingValue(r.Context(), identity)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear the setting")
return
}
if !removed {
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
return
}
publishUserSettingsEvent(r.Context(), h.EventsHub,
apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope))
w.WriteHeader(http.StatusNoContent)
}
// HandleGetEffective resolves any number of keys in one request.
//
// Batched deliberately: a client opening a settings screen needs every key at
// once, and a season view needs several keys across many series. One store read
// serves all of it.
func (h *SettingValuesHandler) HandleGetEffective(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
keys := splitCSV(r.URL.Query().Get("keys"))
if len(keys) == 0 {
// No keys named means every remote definition, which is what a settings
// screen wants and saves clients enumerating the manifest themselves.
for i := range h.contract.Definitions {
def := &h.contract.Definitions[i]
if def.IsRemote() {
keys = append(keys, def.Key)
}
}
}
rc := settingsresolve.Context{
ProfileID: strings.TrimSpace(apimw.GetProfileID(r.Context())),
DeviceID: deviceMetadataFromRequest(r).DeviceID,
LibraryIDs: parseIntCSV(r.URL.Query().Get("library_ids")),
SeriesIDs: splitCSV(r.URL.Query().Get("series_ids")),
}
resolved, err := h.resolver.Resolve(r.Context(), store, rc, keys, h.constraintsFor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve settings")
return
}
out := make([]effectiveSettingValueResponse, 0, len(resolved))
for _, eff := range resolved {
out = append(out, effectiveToResponse(eff))
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": out,
fieldRevision: h.contract.Revision,
})
}
// policyInputMaxPlaybackQuality is the policy_input name the contract binds
// playback.preferred_quality's ceiling to. It must match the manifest's
// constrained_by.policy_input, which is how the resolver looks the limit up.
const policyInputMaxPlaybackQuality = "max_playback_quality"
// constraintsFor gathers the policy inputs that narrow this viewer's settings.
//
// The routes are mounted inside RequireViewerAccess, so the resolved access
// scope is already on the context. Scope.MaxPlaybackQuality holds a literal
// member of the contract's quality enum ("1080p", "2160p"), so it feeds the
// ceiling on playback.preferred_quality directly — no translation table. An
// empty value means the policy does not cap this viewer, expressed by omitting
// the key so the resolver leaves the preference alone.
//
// catalog.metadata_language deliberately gets no constraint: the manifest notes
// record that the allowlist draft was circular, because the policy input it
// would bind to is populated from the very preference it would narrow.
func (h *SettingValuesHandler) constraintsFor(r *http.Request) settingsresolve.Constraints {
scope, ok := access.GetScope(r.Context())
if !ok {
return nil
}
quality := strings.TrimSpace(scope.MaxPlaybackQuality)
if quality == "" {
return nil
}
limit, err := json.Marshal(quality)
if err != nil {
return nil
}
return settingsresolve.Constraints{policyInputMaxPlaybackQuality: limit}
}
// recordMutation stores the idempotency receipt after a successful write.
func (h *SettingValuesHandler) recordMutation(
r *http.Request,
store userstore.UserStore,
mutationID, requestHash string,
identity userstore.SettingIdentity,
value json.RawMessage,
) {
receipt, _ := json.Marshal(settingValueResponse{
Key: identity.Key,
Scope: string(identity.Scope),
ProfileID: identity.ProfileID,
DeviceID: identity.DeviceID,
LibraryID: identity.LibraryID,
SeriesID: identity.SeriesID,
Value: value,
})
// Best effort: the write already succeeded, and failing the request now
// would tell the client the opposite of the truth. A missing receipt costs
// at most a duplicate write on retry, which upsert makes harmless.
_, _, _ = store.PutSettingMutation(r.Context(), userstore.SettingMutationRecord{
MutationID: mutationID,
RequestHash: requestHash,
Result: receipt,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
})
}
func (h *SettingValuesHandler) storeFor(w http.ResponseWriter, r *http.Request) (userstore.UserStore, bool) {
store, err := h.storeProvider.ForUser(r.Context(), apimw.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return nil, false
}
return store, true
}
func (h *SettingValuesHandler) definitionFor(w http.ResponseWriter, key string) (*settingscontract.Definition, bool) {
def, ok := h.contract.Lookup(key)
if !ok {
writeError(w, http.StatusNotFound, "unknown_setting",
"No setting named "+key+" exists in this server's contract")
return nil, false
}
if !def.IsRemote() {
writeError(w, http.StatusBadRequest, "client_local_setting",
key+" is a device-local setting and is never stored by the server")
return nil, false
}
return def, true
}
// identityFromRequest builds and validates the scope identity a request names.
//
// Scope comes from the query string rather than the path so one route serves
// every scope; the store's own Validate then enforces that the identity fields
// match the scope, which is the same check the database CHECK constraint makes.
func (h *SettingValuesHandler) identityFromRequest(
w http.ResponseWriter, r *http.Request,
) (userstore.SettingIdentity, bool) {
key := chi.URLParam(r, "key")
if strings.TrimSpace(key) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "A setting key is required")
return userstore.SettingIdentity{}, false
}
if _, ok := h.definitionFor(w, key); !ok {
return userstore.SettingIdentity{}, false
}
query := r.URL.Query()
scope := settingscontract.Scope(strings.TrimSpace(query.Get("scope")))
if scope == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"A scope is required: account, profile, profile_device, profile_library or profile_series")
return userstore.SettingIdentity{}, false
}
identity := userstore.SettingIdentity{Key: key, Scope: scope}
// Profile and device come from the session headers rather than the query,
// so one profile cannot write another's settings by naming it.
if scope != settingscontract.ScopeAccount {
identity.ProfileID = strings.TrimSpace(apimw.GetProfileID(r.Context()))
if identity.ProfileID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"X-Profile-Id header is required for this scope")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileDevice {
identity.DeviceID = deviceMetadataFromRequest(r).DeviceID
if identity.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"X-Silo-Device-Id header is required for a device override")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileLibrary {
libraryID, err := strconv.Atoi(strings.TrimSpace(query.Get("library_id")))
if err != nil || libraryID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request",
"library_id is required for a library override")
return userstore.SettingIdentity{}, false
}
identity.LibraryID = libraryID
}
if scope == settingscontract.ScopeProfileSeries {
identity.SeriesID = strings.TrimSpace(query.Get("series_id"))
if identity.SeriesID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"series_id is required for a series override")
return userstore.SettingIdentity{}, false
}
}
if err := identity.Validate(); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return userstore.SettingIdentity{}, false
}
// The contract decides where a setting may be written, independently of
// whether the identity is well formed.
def, _ := h.contract.Lookup(key)
if !def.AllowsScope(scope) {
writeError(w, http.StatusBadRequest, "scope_not_allowed",
key+" cannot be set at "+string(scope))
return userstore.SettingIdentity{}, false
}
return identity, true
}
func settingValueToResponse(value userstore.SettingValue) settingValueResponse {
return settingValueResponse{
Key: value.Key,
Scope: string(value.Scope),
ProfileID: value.ProfileID,
DeviceID: value.DeviceID,
LibraryID: value.LibraryID,
SeriesID: value.SeriesID,
Value: value.Value,
Revision: value.Revision,
UpdatedAt: value.UpdatedAt,
}
}
func effectiveToResponse(eff settingsresolve.Effective) effectiveSettingValueResponse {
out := effectiveSettingValueResponse{
Key: eff.Key,
Value: eff.Value,
Source: string(eff.Source),
StoredValue: eff.StoredValue,
Constrained: eff.Constrained,
}
if eff.ConstraintKind != "" {
out.ConstraintKind = string(eff.ConstraintKind)
}
if eff.Identity != nil {
out.Scope = string(eff.Identity.Scope)
out.ProfileID = eff.Identity.ProfileID
out.DeviceID = eff.Identity.DeviceID
out.LibraryID = eff.Identity.LibraryID
out.SeriesID = eff.Identity.SeriesID
}
return out
}
// hashMutationRequest fingerprints what a mutation id was used for, so a reused
// id carrying different content is a conflict rather than a silent replay of
// the wrong write.
func hashMutationRequest(identity userstore.SettingIdentity, value json.RawMessage) string {
sum := sha256.New()
sum.Write([]byte(identity.Key))
sum.Write([]byte{0})
sum.Write([]byte(identity.Scope))
sum.Write([]byte{0})
sum.Write([]byte(identity.ProfileID))
sum.Write([]byte{0})
sum.Write([]byte(identity.DeviceID))
sum.Write([]byte{0})
sum.Write([]byte(strconv.Itoa(identity.LibraryID)))
sum.Write([]byte{0})
sum.Write([]byte(identity.SeriesID))
sum.Write([]byte{0})
sum.Write(value)
return hex.EncodeToString(sum.Sum(nil))
}
func writeRawJSON(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(body)
}
// etagMatches handles the comma-separated If-None-Match list, including "*".
func etagMatches(header, etag string) bool {
header = strings.TrimSpace(header)
if header == "*" {
return true
}
for _, candidate := range strings.Split(header, ",") {
if strings.TrimSpace(candidate) == etag {
return true
}
}
return false
}
func splitCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func parseIntCSV(raw string) []int {
parts := splitCSV(raw)
out := make([]int, 0, len(parts))
for _, part := range parts {
if value, err := strconv.Atoi(part); err == nil && value > 0 {
out = append(out, value)
}
}
return out
}