* 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
521 lines
17 KiB
Go
521 lines
17 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// settingValueColumns is the projection every read shares, in the order
|
|
// scanSettingValue expects.
|
|
const settingValueColumns = `key, scope, profile_id, client_family, device_id, library_id, series_id,
|
|
value, revision, created_at, updated_at`
|
|
|
|
// settingConflictTargets maps a scope to the partial unique index that enforces
|
|
// one explicit value per identity. The upsert names the matching target so a
|
|
// repeated write updates its own row rather than inserting a duplicate.
|
|
var settingConflictTargets = map[settingscontract.Scope]string{
|
|
settingscontract.ScopeAccount: "(user_id, key) WHERE scope = 'account'",
|
|
settingscontract.ScopeProfile: "(user_id, profile_id, key) WHERE scope = 'profile'",
|
|
settingscontract.ScopeProfileClient: "(user_id, profile_id, client_family, key) WHERE scope = 'profile_client'",
|
|
settingscontract.ScopeProfileDevice: "(user_id, profile_id, device_id, key) WHERE scope = 'profile_device'",
|
|
settingscontract.ScopeProfileLibrary: "(user_id, profile_id, library_id, key) WHERE scope = 'profile_library'",
|
|
settingscontract.ScopeProfileSeries: "(user_id, profile_id, series_id, key) WHERE scope = 'profile_series'",
|
|
}
|
|
|
|
// settingIdentityPredicate returns the WHERE fragment and bind arguments that
|
|
// address exactly one row. Every scope compares only the columns it populates,
|
|
// so no clause ever has to reason about NULL equality.
|
|
func settingIdentityPredicate(userID int, id userstore.SettingIdentity) (string, []any) {
|
|
args := []any{userID, id.Key, string(id.Scope)}
|
|
clause := "user_id = $1 AND key = $2 AND scope = $3"
|
|
switch id.Scope {
|
|
case settingscontract.ScopeProfile:
|
|
args = append(args, id.ProfileID)
|
|
clause += " AND profile_id = $4"
|
|
case settingscontract.ScopeProfileClient:
|
|
args = append(args, id.ProfileID, string(id.ClientFamily))
|
|
clause += " AND profile_id = $4 AND client_family = $5"
|
|
case settingscontract.ScopeProfileDevice:
|
|
args = append(args, id.ProfileID, id.DeviceID)
|
|
clause += " AND profile_id = $4 AND device_id = $5"
|
|
case settingscontract.ScopeProfileLibrary:
|
|
args = append(args, id.ProfileID, id.LibraryID)
|
|
clause += " AND profile_id = $4 AND library_id = $5"
|
|
case settingscontract.ScopeProfileSeries:
|
|
args = append(args, id.ProfileID, id.SeriesID)
|
|
clause += " AND profile_id = $4 AND series_id = $5"
|
|
}
|
|
return clause, args
|
|
}
|
|
|
|
func (s *PostgresUserStore) GetSettingValue(
|
|
ctx context.Context,
|
|
id userstore.SettingIdentity,
|
|
) (*userstore.SettingValue, error) {
|
|
return getSettingValue(ctx, s.pool, s.userID, id)
|
|
}
|
|
|
|
func getSettingValue(
|
|
ctx context.Context,
|
|
exec preferenceSettingsExecutor,
|
|
userID int,
|
|
id userstore.SettingIdentity,
|
|
) (*userstore.SettingValue, error) {
|
|
if err := id.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
clause, args := settingIdentityPredicate(userID, id)
|
|
row := exec.QueryRow(ctx,
|
|
"SELECT "+settingValueColumns+" FROM user_setting_values WHERE "+clause,
|
|
args...,
|
|
)
|
|
value, err := scanSettingValue(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting setting value %q at %s: %w", id.Key, id.Scope, err)
|
|
}
|
|
return &value, nil
|
|
}
|
|
|
|
// ListSettingValuesForResolution collects every candidate row for a resolution
|
|
// request in one query. The predicate covers all six scopes at once: ranking by
|
|
// each definition's resolution order happens in Go, so a multi-scope chain still
|
|
// costs one round trip and one index scan rather than one lookup per scope and key.
|
|
func (s *PostgresUserStore) ListSettingValuesForResolution(
|
|
ctx context.Context,
|
|
query userstore.SettingResolutionQuery,
|
|
) ([]userstore.SettingValue, error) {
|
|
q := query.Normalized()
|
|
if len(q.Keys) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT `+settingValueColumns+`
|
|
FROM user_setting_values
|
|
WHERE user_id = $1
|
|
AND key = ANY($2::text[])
|
|
AND (
|
|
scope = 'account'
|
|
OR (
|
|
profile_id = ANY($3::text[])
|
|
AND (
|
|
scope = 'profile'
|
|
OR (scope = 'profile_client' AND client_family = $4)
|
|
OR (scope = 'profile_device' AND device_id = $5)
|
|
OR (scope = 'profile_library' AND library_id = ANY($6::int[]))
|
|
OR (scope = 'profile_series' AND series_id = ANY($7::text[]))
|
|
)
|
|
)
|
|
)
|
|
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
|
|
COALESCE(library_id, 0), COALESCE(series_id, '')`,
|
|
s.userID, q.Keys, q.ProfileIDs, string(q.ClientFamily), q.DeviceID, q.LibraryIDs, q.SeriesIDs,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing setting values for resolution: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var values []userstore.SettingValue
|
|
for rows.Next() {
|
|
value, err := scanSettingValue(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning setting value: %w", err)
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
return values, rows.Err()
|
|
}
|
|
|
|
// ListAllSettingValues returns every stored explicit value across all scopes,
|
|
// ordered by (key, scope, identity) so repeated reads page through the same
|
|
// sequence. It backs the admin inspection surface, which wants the stored
|
|
// truth rather than a resolution.
|
|
func (s *PostgresUserStore) ListAllSettingValues(ctx context.Context) ([]userstore.SettingValue, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT `+settingValueColumns+`
|
|
FROM user_setting_values
|
|
WHERE user_id = $1
|
|
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
|
|
COALESCE(library_id, 0), COALESCE(series_id, '')`,
|
|
s.userID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing all setting values: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var values []userstore.SettingValue
|
|
for rows.Next() {
|
|
value, err := scanSettingValue(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning setting value: %w", err)
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
return values, rows.Err()
|
|
}
|
|
|
|
func (s *PostgresUserStore) UpsertSettingValue(
|
|
ctx context.Context,
|
|
id userstore.SettingIdentity,
|
|
value json.RawMessage,
|
|
) (*userstore.SettingValue, error) {
|
|
return upsertSettingValue(ctx, s.pool, s.userID, id, value)
|
|
}
|
|
|
|
func upsertSettingValue(
|
|
ctx context.Context,
|
|
exec preferenceSettingsExecutor,
|
|
userID int,
|
|
id userstore.SettingIdentity,
|
|
value json.RawMessage,
|
|
) (*userstore.SettingValue, error) {
|
|
if err := id.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := userstore.ValidateSettingValueJSON(value); err != nil {
|
|
return nil, err
|
|
}
|
|
target, ok := settingConflictTargets[id.Scope]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %q has no storage identity", userstore.ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
|
|
row := exec.QueryRow(ctx, fmt.Sprintf(`
|
|
INSERT INTO user_setting_values
|
|
(user_id, key, scope, profile_id, client_family, device_id, library_id, series_id, value)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
ON CONFLICT %s DO UPDATE SET
|
|
value = excluded.value,
|
|
revision = user_setting_values.revision + 1,
|
|
updated_at = now()
|
|
RETURNING %s`, target, settingValueColumns),
|
|
userID, id.Key, string(id.Scope),
|
|
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
|
|
nullableInt(id.LibraryID), nullableText(id.SeriesID),
|
|
[]byte(value),
|
|
)
|
|
stored, err := scanSettingValue(row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upserting setting value %q at %s: %w", id.Key, id.Scope, err)
|
|
}
|
|
return &stored, nil
|
|
}
|
|
|
|
// CompareAndSetSettingValue writes only when expectedRevision still names the
|
|
// current row. Revision zero is insert-if-absent. PostgreSQL performs either
|
|
// comparison and write in one statement, so concurrent document mutations can
|
|
// retry without overwriting one another.
|
|
func (s *PostgresUserStore) CompareAndSetSettingValue(
|
|
ctx context.Context,
|
|
id userstore.SettingIdentity,
|
|
value json.RawMessage,
|
|
expectedRevision int64,
|
|
) (*userstore.SettingValue, error) {
|
|
return compareAndSetSettingValue(ctx, s.pool, s.userID, id, value, expectedRevision)
|
|
}
|
|
|
|
func compareAndSetSettingValue(
|
|
ctx context.Context,
|
|
exec preferenceSettingsExecutor,
|
|
userID int,
|
|
id userstore.SettingIdentity,
|
|
value json.RawMessage,
|
|
expectedRevision int64,
|
|
) (*userstore.SettingValue, error) {
|
|
if err := id.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := userstore.ValidateSettingValueJSON(value); err != nil {
|
|
return nil, err
|
|
}
|
|
if expectedRevision < 0 {
|
|
return nil, fmt.Errorf("%w: expected revision must be non-negative", userstore.ErrInvalidSettingIdentity)
|
|
}
|
|
|
|
var row pgx.Row
|
|
if expectedRevision == 0 {
|
|
target, ok := settingConflictTargets[id.Scope]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %q has no storage identity", userstore.ErrInvalidSettingIdentity, id.Scope)
|
|
}
|
|
row = exec.QueryRow(ctx, fmt.Sprintf(`
|
|
INSERT INTO user_setting_values
|
|
(user_id, key, scope, profile_id, client_family, device_id, library_id, series_id, value)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
ON CONFLICT %s DO NOTHING
|
|
RETURNING %s`, target, settingValueColumns),
|
|
userID, id.Key, string(id.Scope),
|
|
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
|
|
nullableInt(id.LibraryID), nullableText(id.SeriesID), []byte(value),
|
|
)
|
|
} else {
|
|
clause, args := settingIdentityPredicate(userID, id)
|
|
valuePosition := len(args) + 1
|
|
revisionPosition := valuePosition + 1
|
|
args = append(args, []byte(value), expectedRevision)
|
|
row = exec.QueryRow(ctx, fmt.Sprintf(`
|
|
UPDATE user_setting_values
|
|
SET value = $%d, revision = revision + 1, updated_at = now()
|
|
WHERE %s AND revision = $%d
|
|
RETURNING %s`, valuePosition, clause, revisionPosition, settingValueColumns), args...)
|
|
}
|
|
|
|
stored, err := scanSettingValue(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, userstore.ErrSettingValueRevisionConflict
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("compare-and-setting value %q at %s: %w", id.Key, id.Scope, err)
|
|
}
|
|
return &stored, nil
|
|
}
|
|
|
|
func (s *PostgresUserStore) DeleteSettingValue(ctx context.Context, id userstore.SettingIdentity) (bool, error) {
|
|
return deleteSettingValue(ctx, s.pool, s.userID, id)
|
|
}
|
|
|
|
func deleteSettingValue(
|
|
ctx context.Context,
|
|
exec preferenceSettingsExecutor,
|
|
userID int,
|
|
id userstore.SettingIdentity,
|
|
) (bool, error) {
|
|
if err := id.Validate(); err != nil {
|
|
return false, err
|
|
}
|
|
clause, args := settingIdentityPredicate(userID, id)
|
|
tag, err := exec.Exec(ctx, "DELETE FROM user_setting_values WHERE "+clause, args...)
|
|
if err != nil {
|
|
return false, fmt.Errorf("deleting setting value %q at %s: %w", id.Key, id.Scope, err)
|
|
}
|
|
return tag.RowsAffected() > 0, nil
|
|
}
|
|
|
|
// DeleteSettingValuesForProfile removes every profile-anchored value for one
|
|
// profile. Account-scope rows carry a NULL profile_id and survive, which is what
|
|
// deleting one household member out of an account has to mean.
|
|
func (s *PostgresUserStore) DeleteSettingValuesForProfile(ctx context.Context, profileID string) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx,
|
|
"DELETE FROM user_setting_values WHERE user_id = $1 AND profile_id = $2",
|
|
s.userID, profileID,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting setting values for profile %q: %w", profileID, err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
func (s *PostgresUserStore) DeleteSettingValuesForDevice(ctx context.Context, profileID, deviceID string) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
DELETE FROM user_setting_values
|
|
WHERE user_id = $1 AND scope = 'profile_device' AND profile_id = $2 AND device_id = $3`,
|
|
s.userID, profileID, deviceID,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting setting values for device %q: %w", deviceID, err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
func (s *PostgresUserStore) DeleteSettingValuesForLibrary(ctx context.Context, libraryID int) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
DELETE FROM user_setting_values
|
|
WHERE user_id = $1 AND scope = 'profile_library' AND library_id = $2`,
|
|
s.userID, libraryID,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting setting values for library %d: %w", libraryID, err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
func (s *PostgresUserStore) DeleteSettingValuesForSeries(ctx context.Context, seriesID string) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
DELETE FROM user_setting_values
|
|
WHERE user_id = $1 AND scope = 'profile_series' AND series_id = $2`,
|
|
s.userID, seriesID,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting setting values for series %q: %w", seriesID, err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
func (s *PostgresUserStore) GetSettingMutation(
|
|
ctx context.Context,
|
|
mutationID string,
|
|
) (*userstore.SettingMutationRecord, error) {
|
|
return getSettingMutation(ctx, s.pool, s.userID, mutationID)
|
|
}
|
|
|
|
func getSettingMutation(
|
|
ctx context.Context,
|
|
exec preferenceSettingsExecutor,
|
|
userID int,
|
|
mutationID string,
|
|
) (*userstore.SettingMutationRecord, error) {
|
|
row := exec.QueryRow(ctx, `
|
|
SELECT mutation_id, request_hash, result, created_at, expires_at
|
|
FROM user_setting_mutations
|
|
WHERE user_id = $1 AND mutation_id = $2`,
|
|
userID, mutationID,
|
|
)
|
|
record, err := scanSettingMutation(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting setting mutation %q: %w", mutationID, err)
|
|
}
|
|
return &record, nil
|
|
}
|
|
|
|
// PutSettingMutation never overwrites a receipt: DO NOTHING plus a second read
|
|
// keeps a replayed mutation_id answering with the result the first attempt
|
|
// produced, which is what makes a client's retry idempotent rather than a
|
|
// silent re-run.
|
|
func (s *PostgresUserStore) PutSettingMutation(
|
|
ctx context.Context,
|
|
record userstore.SettingMutationRecord,
|
|
) (userstore.SettingMutationRecord, bool, error) {
|
|
return putSettingMutation(ctx, s.pool, s.userID, record)
|
|
}
|
|
|
|
func putSettingMutation(
|
|
ctx context.Context,
|
|
exec preferenceSettingsExecutor,
|
|
userID int,
|
|
record userstore.SettingMutationRecord,
|
|
) (userstore.SettingMutationRecord, bool, error) {
|
|
if err := record.Validate(); err != nil {
|
|
return userstore.SettingMutationRecord{}, false, err
|
|
}
|
|
|
|
row := exec.QueryRow(ctx, `
|
|
INSERT INTO user_setting_mutations (user_id, mutation_id, request_hash, result, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (user_id, mutation_id) DO NOTHING
|
|
RETURNING mutation_id, request_hash, result, created_at, expires_at`,
|
|
userID, record.MutationID, record.RequestHash, []byte(record.Result), record.ExpiresAt,
|
|
)
|
|
stored, err := scanSettingMutation(row)
|
|
if err == nil {
|
|
return stored, true, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return userstore.SettingMutationRecord{}, false, fmt.Errorf("recording setting mutation %q: %w", record.MutationID, err)
|
|
}
|
|
|
|
existing, err := getSettingMutation(ctx, exec, userID, record.MutationID)
|
|
if err != nil {
|
|
return userstore.SettingMutationRecord{}, false, err
|
|
}
|
|
if existing == nil {
|
|
// The conflicting row was swept between the insert and this read; the
|
|
// caller can safely retry rather than receive a phantom conflict.
|
|
return userstore.SettingMutationRecord{}, false, fmt.Errorf(
|
|
"recording setting mutation %q: conflicting receipt disappeared", record.MutationID)
|
|
}
|
|
return *existing, false, nil
|
|
}
|
|
|
|
func (s *PostgresUserStore) DeleteExpiredSettingMutations(ctx context.Context, before time.Time) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx,
|
|
"DELETE FROM user_setting_mutations WHERE user_id = $1 AND expires_at <= $2",
|
|
s.userID, before,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sweeping expired setting mutations: %w", err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// pgxRow is the subset of pgx.Row and pgx.Rows the scan helpers need.
|
|
type pgxRow interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanSettingValue(row pgxRow) (userstore.SettingValue, error) {
|
|
var (
|
|
value userstore.SettingValue
|
|
scope string
|
|
profileID *string
|
|
clientFamily *string
|
|
deviceID *string
|
|
libraryID *int
|
|
seriesID *string
|
|
raw []byte
|
|
createdAt time.Time
|
|
updatedAt time.Time
|
|
)
|
|
if err := row.Scan(
|
|
&value.Key, &scope, &profileID, &clientFamily, &deviceID, &libraryID, &seriesID,
|
|
&raw, &value.Revision, &createdAt, &updatedAt,
|
|
); err != nil {
|
|
return userstore.SettingValue{}, err
|
|
}
|
|
value.Scope = settingscontract.Scope(scope)
|
|
if profileID != nil {
|
|
value.ProfileID = *profileID
|
|
}
|
|
if clientFamily != nil {
|
|
value.ClientFamily = settingscontract.ClientFamily(*clientFamily)
|
|
}
|
|
if deviceID != nil {
|
|
value.DeviceID = *deviceID
|
|
}
|
|
if libraryID != nil {
|
|
value.LibraryID = *libraryID
|
|
}
|
|
if seriesID != nil {
|
|
value.SeriesID = *seriesID
|
|
}
|
|
value.Value = json.RawMessage(raw)
|
|
value.CreatedAt = timeToString(createdAt)
|
|
value.UpdatedAt = timeToString(updatedAt)
|
|
return value, nil
|
|
}
|
|
|
|
func scanSettingMutation(row pgxRow) (userstore.SettingMutationRecord, error) {
|
|
var (
|
|
record userstore.SettingMutationRecord
|
|
raw []byte
|
|
)
|
|
if err := row.Scan(
|
|
&record.MutationID, &record.RequestHash, &raw, &record.CreatedAt, &record.ExpiresAt,
|
|
); err != nil {
|
|
return userstore.SettingMutationRecord{}, err
|
|
}
|
|
record.Result = json.RawMessage(raw)
|
|
record.CreatedAt = record.CreatedAt.UTC()
|
|
record.ExpiresAt = record.ExpiresAt.UTC()
|
|
return record, nil
|
|
}
|
|
|
|
func nullableText(value string) *string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|
|
|
|
func nullableInt(value int) *int {
|
|
if value == 0 {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|