diff --git a/internal/api/handlers/settings_values.go b/internal/api/handlers/settings_values.go new file mode 100644 index 00000000..fa1120a2 --- /dev/null +++ b/internal/api/handlers/settings_values.go @@ -0,0 +1,559 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "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 +} + +// 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 + } + 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 + } + 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, + }) +} + +// constraintsFor gathers the policy inputs that narrow this viewer's settings. +// +// Nothing is wired yet: internal/policy resolves max_playback_quality and the +// metadata-language allowlist against its own inputs, and binding them here is +// the remaining half of the preferences-versus-restrictions seam. Returning nil +// means no setting is narrowed, which is the same answer the legacy endpoint +// gave — so this is a gap, not a regression. +func (h *SettingValuesHandler) constraintsFor(_ *http.Request) settingsresolve.Constraints { + return nil +} + +// 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 +} diff --git a/internal/api/handlers/settings_values_test.go b/internal/api/handlers/settings_values_test.go new file mode 100644 index 00000000..99423013 --- /dev/null +++ b/internal/api/handlers/settings_values_test.go @@ -0,0 +1,424 @@ +package handlers + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/settingscontract" + "github.com/Silo-Server/silo-server/internal/userdb" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +func newValuesTestHandler(t *testing.T) (*SettingValuesHandler, userstore.UserStore) { + t.Helper() + + dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + + "?mode=memory&cache=shared" + db, err := sql.Open("sqlite3", dsn) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := userdb.InitSchema(db); err != nil { + t.Fatalf("init schema: %v", err) + } + + store := userdb.NewSQLiteUserStore(db) + if err := store.CreateProfile(context.Background(), + userstore.Profile{ID: "profile-1", Name: "Main"}); err != nil { + t.Fatalf("create profile: %v", err) + } + + contract, err := settingscontract.Load() + if err != nil { + t.Fatalf("loading contract: %v", err) + } + return NewSettingValuesHandler(testUserStoreProvider{store: store}, contract), store +} + +// valuesRequest builds a request carrying the session identity the handlers +// read: user, profile and device all come from context or headers rather than +// the query string, so one profile cannot address another's settings. +func valuesRequest(method, target string, body []byte) *http.Request { + var req *http.Request + if body == nil { + req = httptest.NewRequest(method, target, nil) + } else { + req = httptest.NewRequest(method, target, bytes.NewReader(body)) + } + req.Header.Set(deviceIDHeader, "device-1") + ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1}) + return req.WithContext(apimw.SetProfileID(ctx, "profile-1")) +} + +// routeValues wires the chi URL params the handlers read from the path. +func routeValues(t *testing.T, h *SettingValuesHandler, method, key, query string, body []byte) *httptest.ResponseRecorder { + t.Helper() + target := "/settings/values/" + key + if query != "" { + target += "?" + query + } + req := valuesRequest(method, target, body) + + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("key", key) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) + + rec := httptest.NewRecorder() + switch method { + case http.MethodGet: + h.HandleGetValue(rec, req) + case http.MethodPut: + h.HandleSetValue(rec, req) + case http.MethodDelete: + h.HandleDeleteValue(rec, req) + } + return rec +} + +func TestSettingValuesRoundTrip(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + // Nothing stored yet. + if rec := routeValues(t, handler, http.MethodGet, + "playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNotFound { + t.Fatalf("GET before write = %d, want 404", rec.Code) + } + + rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + "scope=profile", []byte(`{"value":"ja"}`)) + if rec.Code != http.StatusOK { + t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String()) + } + var stored settingValueResponse + if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil { + t.Fatalf("decoding PUT response: %v", err) + } + if string(stored.Value) != `"ja"` || stored.Scope != "profile" { + t.Errorf("stored %s at %s, want \"ja\" at profile", stored.Value, stored.Scope) + } + + rec = routeValues(t, handler, http.MethodGet, "playback.subtitle_language", "scope=profile", nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET after write = %d", rec.Code) + } + + if rec := routeValues(t, handler, http.MethodDelete, + "playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNoContent { + t.Fatalf("DELETE = %d", rec.Code) + } + if rec := routeValues(t, handler, http.MethodGet, + "playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNotFound { + t.Errorf("GET after delete = %d, want 404", rec.Code) + } +} + +// TestUnknownKeysAreRefused is the extension bag closing. The legacy endpoint +// stored any unregistered key as an unvalidated string, which is how six ui.* +// settings and five orphan keys reached production untyped. +func TestUnknownKeysAreRefused(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + rec := routeValues(t, handler, http.MethodPut, "totally.invented.key", + "scope=profile", []byte(`{"value":"x"}`)) + if rec.Code != http.StatusNotFound { + t.Fatalf("PUT of an unknown key = %d, want 404: %s", rec.Code, rec.Body.String()) + } + + // A contract-known local setting is not server storage either. + rec = routeValues(t, handler, http.MethodPut, "downloads.wifi_only", + "scope=profile", []byte(`{"value":true}`)) + if rec.Code != http.StatusBadRequest { + t.Errorf("PUT of a client_local key = %d, want 400: %s", rec.Code, rec.Body.String()) + } +} + +// TestInvalidValuesAreRefusedByType covers what the string-only endpoint could +// not check at all. +func TestInvalidValuesAreRefusedByType(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + for name, tc := range map[string]struct{ key, body string }{ + "enum member": {"playback.subtitle_mode", `{"value":"sideways"}`}, + "integer range": {"playback.next_up_prompt_seconds", `{"value":9999}`}, + "wrong type": {"playback.auto_skip_intro", `{"value":"yes"}`}, + "quoted number": {"playback.next_up_prompt_seconds", `{"value":"30"}`}, + "bad language": {"playback.subtitle_language", `{"value":"!!!"}`}, + "object schema": {"playback.subtitle_appearance", `{"value":{"fontSize":"enormous"}}`}, + "null when not ok": {"playback.subtitle_mode", `{"value":null}`}, + } { + t.Run(name, func(t *testing.T) { + rec := routeValues(t, handler, http.MethodPut, tc.key, "scope=profile", []byte(tc.body)) + if rec.Code != http.StatusBadRequest { + t.Errorf("PUT %s = %d, want 400: %s", tc.body, rec.Code, rec.Body.String()) + } + }) + } +} + +// TestScopeMustBeAllowedByTheContract. A definition declares where it may be +// written; the identity being well-formed is a separate question. +func TestScopeMustBeAllowedByTheContract(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + // ui.custom_css is profile-only, so a device override is refused. + rec := routeValues(t, handler, http.MethodPut, "ui.custom_css", + "scope=profile_device", []byte(`{"value":"body{}"}`)) + if rec.Code != http.StatusBadRequest { + t.Errorf("device write to a profile-only setting = %d, want 400: %s", + rec.Code, rec.Body.String()) + } + + // A missing scope is a request error rather than a silent default: writing + // to the wrong scope is exactly the mistake this API exists to prevent. + rec = routeValues(t, handler, http.MethodPut, "playback.subtitle_mode", "", + []byte(`{"value":"always"}`)) + if rec.Code != http.StatusBadRequest { + t.Errorf("write with no scope = %d, want 400", rec.Code) + } +} + +// TestLibraryAndSeriesScopesNeedTheirIdentity. +func TestLibraryAndSeriesScopesNeedTheirIdentity(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + "scope=profile_library", []byte(`{"value":"de"}`)); rec.Code != http.StatusBadRequest { + t.Errorf("library scope without library_id = %d, want 400", rec.Code) + } + if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + "scope=profile_series", []byte(`{"value":"de"}`)); rec.Code != http.StatusBadRequest { + t.Errorf("series scope without series_id = %d, want 400", rec.Code) + } + + if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + "scope=profile_library&library_id=7", []byte(`{"value":"de"}`)); rec.Code != http.StatusOK { + t.Errorf("library write = %d, want 200", rec.Code) + } + if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + "scope=profile_series&series_id=s1", []byte(`{"value":"ja"}`)); rec.Code != http.StatusOK { + t.Errorf("series write = %d, want 200", rec.Code) + } +} + +// TestEffectiveResolvesThroughTheLadder proves the route is wired to the real +// resolver rather than reading one scope. +func TestEffectiveResolvesThroughTheLadder(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + write := func(query, value string) { + t.Helper() + if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + query, []byte(`{"value":`+value+`}`)); rec.Code != http.StatusOK { + t.Fatalf("seeding %s = %d: %s", query, rec.Code, rec.Body.String()) + } + } + write("scope=profile", `"en"`) + write("scope=profile_device", `"de"`) + write("scope=profile_series&series_id=s1", `"ja"`) + + effective := func(query string) effectiveSettingValueResponse { + t.Helper() + req := valuesRequest(http.MethodGet, "/settings/values/effective?"+query, nil) + rec := httptest.NewRecorder() + handler.HandleGetEffective(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("effective %s = %d: %s", query, rec.Code, rec.Body.String()) + } + var body struct { + Settings []effectiveSettingValueResponse `json:"settings"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decoding: %v", err) + } + if len(body.Settings) != 1 { + t.Fatalf("got %d settings, want 1", len(body.Settings)) + } + return body.Settings[0] + } + + // Without a series context the device override is the most specific match. + got := effective("keys=playback.subtitle_language") + if string(got.Value) != `"de"` || got.Source != "profile_device" { + t.Errorf("no-series resolution = %s from %s, want \"de\" from profile_device", + got.Value, got.Source) + } + + // Naming the series promotes its override. + got = effective("keys=playback.subtitle_language&series_ids=s1") + if string(got.Value) != `"ja"` || got.Source != "profile_series" { + t.Errorf("series resolution = %s from %s, want \"ja\" from profile_series", + got.Value, got.Source) + } + if got.SeriesID != "s1" { + t.Errorf("resolved identity series = %q, want s1 so a client can reset it", got.SeriesID) + } +} + +// TestEffectiveWithNoKeysReturnsEveryRemoteSetting, which is what a settings +// screen opening for the first time asks for. +func TestEffectiveWithNoKeysReturnsEveryRemoteSetting(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + req := valuesRequest(http.MethodGet, "/settings/values/effective", nil) + rec := httptest.NewRecorder() + handler.HandleGetEffective(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("effective = %d: %s", rec.Code, rec.Body.String()) + } + + var body struct { + Settings []effectiveSettingValueResponse `json:"settings"` + Revision int `json:"revision"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decoding: %v", err) + } + + contract, _ := settingscontract.Load() + remote := 0 + for i := range contract.Definitions { + if contract.Definitions[i].IsRemote() { + remote++ + } + } + if len(body.Settings) != remote { + t.Errorf("returned %d settings, want every remote definition (%d)", + len(body.Settings), remote) + } + if body.Revision != contract.Revision { + t.Errorf("revision = %d, want %d", body.Revision, contract.Revision) + } + // Everything unset resolves to its contract default. + for _, setting := range body.Settings { + if setting.Source != string(settingscontract.ScopeDefault) { + t.Errorf("%s resolved from %s with nothing stored", setting.Key, setting.Source) + } + } +} + +// TestMutationIDMakesWritesIdempotent covers the retry a mobile client performs +// when a response is lost. +func TestMutationIDMakesWritesIdempotent(t *testing.T) { + handler, store := newValuesTestHandler(t) + + send := func(mutationID, value string) *httptest.ResponseRecorder { + req := valuesRequest(http.MethodPut, + "/settings/values/playback.subtitle_mode?scope=profile", + []byte(`{"value":`+value+`}`)) + req.Header.Set(mutationIDHeader, mutationID) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("key", "playback.subtitle_mode") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) + rec := httptest.NewRecorder() + handler.HandleSetValue(rec, req) + return rec + } + + if rec := send("mut-1", `"always"`); rec.Code != http.StatusOK { + t.Fatalf("first write = %d: %s", rec.Code, rec.Body.String()) + } + + // The same id and body replays the receipt rather than writing again. + replay := send("mut-1", `"always"`) + if replay.Code != http.StatusOK { + t.Fatalf("replay = %d: %s", replay.Code, replay.Body.String()) + } + if replay.Header().Get("X-Silo-Idempotent-Replay") != "true" { + t.Error("a repeated mutation id was not reported as a replay") + } + + // The same id with different content is a conflict, not a silent overwrite. + conflict := send("mut-1", `"off"`) + if conflict.Code != http.StatusConflict { + t.Errorf("reused id with new content = %d, want 409", conflict.Code) + } + + // The stored value is still the first write's. + value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{ + Key: "playback.subtitle_mode", + Scope: settingscontract.ScopeProfile, + ProfileID: "profile-1", + }) + if err != nil || value == nil { + t.Fatalf("reading stored value: %v", err) + } + if string(value.Value) != `"always"` { + t.Errorf("stored value = %s, want the first write preserved", value.Value) + } +} + +// TestContractIsServedWithAnETag. Clients vendor a pinned copy and generate +// bindings from it, so the common request asks "still the same contract?". +func TestContractIsServedWithAnETag(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + rec := httptest.NewRecorder() + handler.HandleGetContract(rec, httptest.NewRequest(http.MethodGet, "/settings/contract", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET contract = %d", rec.Code) + } + etag := rec.Header().Get("ETag") + if etag == "" { + t.Fatal("no ETag on the contract response") + } + + var manifest map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil { + t.Fatalf("contract body is not JSON: %v", err) + } + // Maintainer notes are stripped from the public projection. + if definitions, ok := manifest["definitions"].([]any); ok && len(definitions) > 0 { + if first, ok := definitions[0].(map[string]any); ok { + if _, leaked := first["notes"]; leaked { + t.Error("maintainer notes leaked into the public contract") + } + } + } + + conditional := httptest.NewRequest(http.MethodGet, "/settings/contract", nil) + conditional.Header.Set("If-None-Match", etag) + rec = httptest.NewRecorder() + handler.HandleGetContract(rec, conditional) + if rec.Code != http.StatusNotModified { + t.Errorf("conditional GET = %d, want 304", rec.Code) + } +} + +func TestCapabilitiesReportTheContractRevision(t *testing.T) { + handler, _ := newValuesTestHandler(t) + + rec := httptest.NewRecorder() + handler.HandleGetCapabilities(rec, + httptest.NewRequest(http.MethodGet, "/settings/contract/capabilities", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("capabilities = %d", rec.Code) + } + + var body struct { + APIVersion int `json:"api_version"` + Revision int `json:"revision"` + Scopes []string `json:"scopes"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decoding: %v", err) + } + contract, _ := settingscontract.Load() + if body.APIVersion != contract.APIVersion || body.Revision != contract.Revision { + t.Errorf("reported %d/%d, want %d/%d", + body.APIVersion, body.Revision, contract.APIVersion, contract.Revision) + } + if len(body.Scopes) != 5 { + t.Errorf("reported %d scopes, want 5", len(body.Scopes)) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 9b8d26a0..af4f6522 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -62,6 +62,7 @@ import ( "github.com/Silo-Server/silo-server/internal/scanqueue" "github.com/Silo-Server/silo-server/internal/secret" "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/settingscontract" "github.com/Silo-Server/silo-server/internal/subtitles" subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai" "github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles" @@ -733,6 +734,7 @@ func NewRouter(deps Dependencies) chi.Router { var progressHandler *handlers.ProgressHandler var collectionHandler *handlers.CollectionHandler var settingsHandler *handlers.SettingsHandler + var settingValuesHandler *handlers.SettingValuesHandler var homeDismissalHandler *handlers.HomeDismissalHandler var subtitlePrefHandler *handlers.SubtitlePrefHandler var audioPrefHandler *handlers.AudioPrefHandler @@ -782,6 +784,13 @@ func NewRouter(deps Dependencies) chi.Router { if settingsRepo != nil { settingsHandler.SetServerSettings(settingsRepo) } + // The canonical settings API. main.go has already loaded and validated + // the contract by the time the router is built, so a failure here is + // unreachable — but the handler is simply omitted rather than panicking, + // which degrades to "no typed settings routes" instead of no server. + if contract, err := settingscontract.Load(); err == nil { + settingValuesHandler = handlers.NewSettingValuesHandler(deps.UserStoreProvider, contract) + } homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider) homeDismissalHandler.EventsHub = deps.EventsHub subtitlePrefHandler = handlers.NewSubtitlePrefHandler(deps.UserStoreProvider) @@ -2214,6 +2223,21 @@ func NewRouter(deps Dependencies) chi.Router { r.Put("/device/{key}", settingsHandler.HandleSetDeviceSetting) r.Delete("/device/{key}", settingsHandler.HandleDeleteDeviceSetting) }) + // The canonical settings API. Registered before the + // catch-all /{key} routes below, which would otherwise + // swallow "contract" and "values" as setting names. + if settingValuesHandler != nil { + r.Get("/contract", settingValuesHandler.HandleGetContract) + r.Get("/contract/capabilities", settingValuesHandler.HandleGetCapabilities) + r.Group(func(r chi.Router) { + r.Use(apimw.RequireProfile) + r.Get("/values/effective", settingValuesHandler.HandleGetEffective) + r.Get("/values/{key}", settingValuesHandler.HandleGetValue) + r.Put("/values/{key}", settingValuesHandler.HandleSetValue) + r.Delete("/values/{key}", settingValuesHandler.HandleDeleteValue) + }) + } + r.Get("/{key}", settingsHandler.HandleGetSetting) r.Put("/{key}", settingsHandler.HandleSetSetting) r.Delete("/{key}", settingsHandler.HandleDeleteSetting)