* test(web): use safe auth placeholders * feat(settings): sync navigation and card customization * fix(settings): address customization review feedback * fix(settings): address customization review feedback * fix(settings): harden customization capability handling
328 lines
12 KiB
Go
328 lines
12 KiB
Go
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
|
)
|
|
|
|
// ErrInvalidSettingIdentity is returned when a setting identity does not match
|
|
// the columns its scope requires. Both backends validate through
|
|
// SettingIdentity.Validate, so a request rejected by one is rejected by the
|
|
// other with the same reason.
|
|
var ErrInvalidSettingIdentity = errors.New("invalid setting identity")
|
|
|
|
// ErrInvalidSettingValue is returned when a stored value is not well-formed
|
|
// JSON. The store checks only structural validity: whether a value satisfies its
|
|
// definition is settingscontract.ValidateValue's job, and that is the single
|
|
// validation path.
|
|
var ErrInvalidSettingValue = errors.New("invalid setting value")
|
|
|
|
// ErrSettingValueRevisionConflict reports that a compare-and-set write did
|
|
// not observe the revision it expected. It is deliberately a storage-level
|
|
// primitive rather than a public settings precondition: semantic mutation
|
|
// endpoints use it to merge concurrent edits without making every client
|
|
// implement a read/rebase/retry loop.
|
|
var ErrSettingValueRevisionConflict = errors.New("setting value revision conflict")
|
|
|
|
// SettingIdentity addresses exactly one canonical setting row: the key plus the
|
|
// context columns its scope requires.
|
|
//
|
|
// Only the fields belonging to Scope are meaningful; Validate enforces that and
|
|
// rejects anything else, so an identity that reaches SQL always matches the
|
|
// table's CHECK constraints.
|
|
type SettingIdentity struct {
|
|
Key string
|
|
Scope settingscontract.Scope
|
|
|
|
ProfileID string
|
|
ClientFamily settingscontract.ClientFamily
|
|
DeviceID string
|
|
LibraryID int
|
|
SeriesID string
|
|
}
|
|
|
|
// Validate reports whether the identity is addressable. It mirrors the scope
|
|
// CHECK constraint on user_setting_values so an invalid identity is rejected
|
|
// before it reaches either backend rather than surfacing as a driver error whose
|
|
// text differs between them.
|
|
//
|
|
// It also rejects ids that are not in canonical (trimmed) form rather than
|
|
// merely non-empty after trimming: the write path binds these fields verbatim,
|
|
// while resolution queries bind their trimmed forms, so a value written under
|
|
// " p1 " would validate, persist, and then never be found by a resolution for
|
|
// p1 — a silently orphaned row.
|
|
func (id SettingIdentity) Validate() error {
|
|
for field, value := range map[string]string{
|
|
"key": id.Key,
|
|
"profile id": id.ProfileID,
|
|
"client family": string(id.ClientFamily),
|
|
"device id": id.DeviceID,
|
|
"series id": id.SeriesID,
|
|
} {
|
|
if value != strings.TrimSpace(value) {
|
|
return fmt.Errorf("%w: %s %q has surrounding whitespace",
|
|
ErrInvalidSettingIdentity, field, value)
|
|
}
|
|
}
|
|
if id.Key == "" {
|
|
return fmt.Errorf("%w: key is required", ErrInvalidSettingIdentity)
|
|
}
|
|
if !id.Scope.IsRemote() {
|
|
return fmt.Errorf("%w: %q is not a remote scope", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
|
|
needProfile := id.Scope != settingscontract.ScopeAccount
|
|
if needProfile && id.ProfileID == "" {
|
|
return fmt.Errorf("%w: scope %q requires a profile id", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
if !needProfile && id.ProfileID != "" {
|
|
return fmt.Errorf("%w: scope %q must not carry a profile id", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
|
|
wantClientFamily := id.Scope == settingscontract.ScopeProfileClient
|
|
if wantClientFamily && !id.ClientFamily.Valid() {
|
|
return fmt.Errorf("%w: scope %q requires client family tv, mobile, tablet, desktop or web",
|
|
ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
if !wantClientFamily && id.ClientFamily != "" {
|
|
return fmt.Errorf("%w: scope %q must not carry a client family", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
|
|
wantDevice := id.Scope == settingscontract.ScopeProfileDevice
|
|
if wantDevice && id.DeviceID == "" {
|
|
return fmt.Errorf("%w: scope %q requires a device id", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
if !wantDevice && id.DeviceID != "" {
|
|
return fmt.Errorf("%w: scope %q must not carry a device id", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
|
|
wantLibrary := id.Scope == settingscontract.ScopeProfileLibrary
|
|
if wantLibrary && id.LibraryID <= 0 {
|
|
return fmt.Errorf("%w: scope %q requires a library id", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
if !wantLibrary && id.LibraryID != 0 {
|
|
return fmt.Errorf("%w: scope %q must not carry a library id", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
|
|
wantSeries := id.Scope == settingscontract.ScopeProfileSeries
|
|
if wantSeries && id.SeriesID == "" {
|
|
return fmt.Errorf("%w: scope %q requires a series id", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
if !wantSeries && id.SeriesID != "" {
|
|
return fmt.Errorf("%w: scope %q must not carry a series id", ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SettingValue is one explicit value stored at one scope. Unset is the absence
|
|
// of a row, which is distinct from false, 0, "" and JSON null.
|
|
type SettingValue struct {
|
|
SettingIdentity
|
|
|
|
// Value is the stored JSON. It is whatever settingscontract.NormalizeValue
|
|
// produced; the store neither interprets nor re-normalizes it.
|
|
Value json.RawMessage
|
|
// Revision increments on every write to this row.
|
|
Revision int64
|
|
// CreatedAt and UpdatedAt are RFC3339 UTC timestamps.
|
|
CreatedAt string
|
|
UpdatedAt string
|
|
}
|
|
|
|
// SettingValueCompareAndSetter is the optional atomic-write capability used
|
|
// by settings whose public API mutates one member of a shared document. An
|
|
// expected revision of zero means the row must not exist; a positive revision
|
|
// means that exact row revision must still be current.
|
|
//
|
|
// UserStore intentionally does not embed this interface. Transaction-scoped
|
|
// and test-only stores that never serve semantic mutation endpoints need not
|
|
// expose an operation they cannot perform, while both production backends do.
|
|
type SettingValueCompareAndSetter interface {
|
|
CompareAndSetSettingValue(
|
|
ctx context.Context,
|
|
id SettingIdentity,
|
|
value json.RawMessage,
|
|
expectedRevision int64,
|
|
) (*SettingValue, error)
|
|
}
|
|
|
|
// SettingMutationWriter is the transaction-scoped settings surface used by an
|
|
// idempotent mutation. Implementations must keep every call made by one
|
|
// WithSettingMutationTransaction callback on the same database transaction.
|
|
// That makes the setting write and its replay receipt one durable unit.
|
|
type SettingMutationWriter interface {
|
|
GetSettingValue(ctx context.Context, id SettingIdentity) (*SettingValue, error)
|
|
UpsertSettingValue(ctx context.Context, id SettingIdentity, value json.RawMessage) (*SettingValue, error)
|
|
CompareAndSetSettingValue(
|
|
ctx context.Context,
|
|
id SettingIdentity,
|
|
value json.RawMessage,
|
|
expectedRevision int64,
|
|
) (*SettingValue, error)
|
|
GetSettingMutation(ctx context.Context, mutationID string) (*SettingMutationRecord, error)
|
|
PutSettingMutation(ctx context.Context, record SettingMutationRecord) (SettingMutationRecord, bool, error)
|
|
}
|
|
|
|
// SettingMutationTransactioner atomically serializes one mutation id, applies
|
|
// its setting write, and records its receipt. The callback commits only when it
|
|
// returns nil. A crash or callback error therefore leaves neither half applied.
|
|
// Concurrent callbacks for the same mutationID must execute one at a time.
|
|
//
|
|
// This is optional rather than embedded in UserStore because only production
|
|
// stores serving idempotent mutation routes need to expose transaction state.
|
|
type SettingMutationTransactioner interface {
|
|
WithSettingMutationTransaction(
|
|
ctx context.Context,
|
|
mutationID string,
|
|
fn func(SettingMutationWriter) error,
|
|
) error
|
|
}
|
|
|
|
// SettingResolutionQuery describes one resolution request: the keys to resolve
|
|
// and every identity they may resolve against.
|
|
//
|
|
// It is deliberately shaped for the batched read. A season view resolving n
|
|
// items passes every library and series id in one query and the resolver ranks
|
|
// the returned candidate rows by each definition's resolution order in Go. Five
|
|
// sequential index lookups per key per item is a rejected implementation.
|
|
type SettingResolutionQuery struct {
|
|
Keys []string
|
|
|
|
// ProfileIDs are the profiles in play. Empty drops every profile-anchored
|
|
// scope, leaving only account-scope candidates.
|
|
//
|
|
// Several ids is the household shape: GET /profiles serves a preference
|
|
// block per profile, so it passes every profile once and the resolver ranks
|
|
// each profile's candidates in Go. One read per profile is a rejected
|
|
// implementation for the same reason one read per item is.
|
|
ProfileIDs []string
|
|
// DeviceID drops profile_device candidates when empty, which is what an
|
|
// unidentified client (jellycompat's DisplayPreferences seed) needs.
|
|
DeviceID string
|
|
// ClientFamily drops profile_client candidates when empty. Unlike the
|
|
// device registry's platform metadata, this is a closed canonical enum and
|
|
// is supplied explicitly by first-party clients.
|
|
ClientFamily settingscontract.ClientFamily
|
|
// LibraryIDs and SeriesIDs carry the content contexts of a batch. Empty
|
|
// slices drop their scope from the candidate set.
|
|
LibraryIDs []int
|
|
SeriesIDs []string
|
|
}
|
|
|
|
// Normalized returns the query with blanks removed and duplicates collapsed, in
|
|
// a stable order. Both backends bind the normalized form, so an empty or
|
|
// whitespace-only id never reaches SQL as a literal and the two backends issue
|
|
// the same predicate for the same request.
|
|
func (q SettingResolutionQuery) Normalized() SettingResolutionQuery {
|
|
return SettingResolutionQuery{
|
|
Keys: compactStrings(q.Keys),
|
|
ProfileIDs: compactStrings(q.ProfileIDs),
|
|
ClientFamily: settingscontract.ClientFamily(strings.TrimSpace(string(q.ClientFamily))),
|
|
DeviceID: strings.TrimSpace(q.DeviceID),
|
|
LibraryIDs: compactPositiveInts(q.LibraryIDs),
|
|
SeriesIDs: compactStrings(q.SeriesIDs),
|
|
}
|
|
}
|
|
|
|
// SettingMutationRecord is the idempotency receipt for one mutation.
|
|
//
|
|
// The mutation endpoint treats a mutation_id as idempotent for at least 30 days:
|
|
// repeating the same id and body returns the prior Result, and reusing an id
|
|
// with different content is a mutation_id_conflict, which is what RequestHash
|
|
// distinguishes.
|
|
type SettingMutationRecord struct {
|
|
MutationID string
|
|
RequestHash string
|
|
// Result is the serialized per-mutation result returned to a repeat of the
|
|
// same request.
|
|
Result json.RawMessage
|
|
// CreatedAt is set by the store when the record is inserted.
|
|
CreatedAt time.Time
|
|
// ExpiresAt bounds retention. It is not self-enforcing: a sweeper deletes
|
|
// expired rows through DeleteExpiredSettingMutations.
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// Validate reports whether the record is storable.
|
|
func (r SettingMutationRecord) Validate() error {
|
|
if strings.TrimSpace(r.MutationID) == "" {
|
|
return fmt.Errorf("%w: mutation id is required", ErrInvalidSettingIdentity)
|
|
}
|
|
if strings.TrimSpace(r.RequestHash) == "" {
|
|
return fmt.Errorf("%w: request hash is required", ErrInvalidSettingIdentity)
|
|
}
|
|
if r.ExpiresAt.IsZero() {
|
|
return fmt.Errorf("%w: expires_at is required", ErrInvalidSettingIdentity)
|
|
}
|
|
return ValidateSettingValueJSON(r.Result)
|
|
}
|
|
|
|
// ValidateSettingValueJSON checks that raw is a non-empty, well-formed JSON
|
|
// document. It is the only value check the store makes: the contract layer has
|
|
// already validated the value against its definition through
|
|
// settingscontract.NormalizeValue, and duplicating that here would be the second
|
|
// validator this contract exists to remove.
|
|
func ValidateSettingValueJSON(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return fmt.Errorf("%w: value is required", ErrInvalidSettingValue)
|
|
}
|
|
if !json.Valid(raw) {
|
|
return fmt.Errorf("%w: value is not well-formed JSON", ErrInvalidSettingValue)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func compactStrings(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(values))
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[trimmed]; dup {
|
|
continue
|
|
}
|
|
seen[trimmed] = struct{}{}
|
|
out = append(out, trimmed)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func compactPositiveInts(values []int) []int {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[int]struct{}, len(values))
|
|
out := make([]int, 0, len(values))
|
|
for _, value := range values {
|
|
if value <= 0 {
|
|
continue
|
|
}
|
|
if _, dup := seen[value]; dup {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
sort.Ints(out)
|
|
return out
|
|
}
|