Compare commits

..
Author SHA1 Message Date
Quick 67a8e7aa22 feat(scanner): detect deleted media files without a full scan
- Add a periodic verify_file_presence task that stats live files per library and marks vanished ones missing, then reconciles memberships
- Skip files under unreachable/suspect-empty roots and abort on bulk disappearance, deferring to a full scan
- Add FileRepository.ListPresentByFolder and MarkMissingByIDs
- Hide files missing from disk in catalog detail playback responses, bounded by a time and count budget
2026-07-25 06:29:55 +00:00
536 changed files with 6850 additions and 71913 deletions
-177
View File
@@ -1,177 +0,0 @@
name: CI
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
GOPROXY: https://proxy.golang.org,direct
GOPRIVATE: github.com/Silo-Server/*
GONOSUMDB: github.com/Silo-Server/*
# Pinned so a lint gate cannot change its mind between two runs of the same
# commit. Built from source below rather than downloaded: a released binary
# refuses to run against a Go version newer than the one it was built with,
# and go.mod tracks Go closely enough that this repo is regularly ahead.
GOLANGCI_LINT_VERSION: v2.12.2
# The default token is read-write. Nothing here needs to write, and a token
# that cannot push is one fewer thing a compromised dependency can reach.
permissions:
contents: read
jobs:
go:
name: Go
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# golangci-lint needs the merge base to tell this branch's lines from
# the ones it inherited.
fetch-depth: 0
persist-credentials: false
# github.com/h2non/bimg binds libvips through cgo and pkg-config, so
# nothing under ./... compiles without the headers. The Dockerfile
# installs the same package in its build stage.
- name: Install libvips
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libvips-dev
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
# cmd/silo embeds the built frontend, so nothing under ./... compiles
# without web/dist. The Go jobs never serve it, so a placeholder is
# enough; the Docker workflow builds the real bundle.
- name: Stub the embedded frontend bundle
run: make embed-stub
- name: Build
run: go build ./...
- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "::error::gofmt is required on:"
echo "$unformatted"
exit 1
fi
- name: Vet
run: go vet ./...
# Scoped to the lines this branch touched. The repo does not pass a full
# golangci-lint run today — there are a few hundred pre-existing findings,
# which is why the Go half of `make lint` has never been enforced — and
# blocking every PR on a cleanup nobody has done would just get the gate
# removed again. New and changed lines have to be clean, so the count only
# falls from here.
- name: Install golangci-lint
run: go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}"
- name: Lint changed lines
env:
# A PR carries its target branch; a push to main compares against
# main's own history, which leaves the merge base at HEAD and lints
# nothing new. Read through the environment rather than interpolated
# into the script.
BASE_REF: ${{ github.base_ref || github.event.repository.default_branch }}
run: |
git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
golangci-lint run --new-from-merge-base="origin/${BASE_REF}" ./...
# A manifest change that does not regenerate leaves every client reading
# stale keys, which the contract exists to prevent.
- name: Verify settings bindings are current
run: make verify-settings-bindings
# Runs the settings-contract gate among everything else: the embedded
# manifest must parse, satisfy its own schema, hold every structural
# invariant, and agree with the live settings registry on keys and
# defaults. Without this job those tests exist but never run.
- name: Test
run: make test-go
web:
name: Web
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false
# The pnpm version comes from web/package.json's packageManager field —
# there is no package.json at the repo root, and `defaults.run` does not
# apply to an action's own inputs.
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
- name: Set up Node
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Format check
run: pnpm run format:check
- name: Typecheck and build
run: pnpm run build
# Includes the appearance-cache ownership tests, which are the regression
# guard for cross-account leaks in the localStorage warm start.
- name: Test
working-directory: .
run: make test-web
# The generated web binding is compared after prettier, so this half of
# the bindings check lives here rather than in the Go job, which has no
# pnpm. It needs Go to run the generator.
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Verify the generated web settings binding is current
working-directory: .
run: make verify-settings-bindings-web
docs:
name: Docs hygiene
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Verify no local paths leaked into committed docs
run: make verify-local-paths
-1
View File
@@ -81,4 +81,3 @@ docker-compose.override.yml
docker-compose.local.yml
.playwright-cli/
output/
/settingsgen
+14 -19
View File
@@ -59,24 +59,19 @@ linters:
misspell:
locale: US
exclusions:
# Anchored regexes, not directory names: `paths` matches anywhere in the
# path, so a bare `web` also excluded internal/jellycompat/web_component.go,
# internal/webhooksync/, internal/notifications/webhook*.go and every other
# non-test file with "web" in its name — 14 files that were being linted
# before.
paths:
- ^web/
- ^migrations/
issues:
exclude-dirs:
- web
- migrations
rules:
# Allow repeated strings in test files
- path: _test\.go
linters:
- goconst
exclude-rules:
# Allow repeated strings in test files
- path: _test\.go
linters:
- goconst
# Allow unchecked errors in test cleanup/defer
- path: _test\.go
text: "Error return value is not checked"
linters:
- errcheck
# Allow unchecked errors in test cleanup/defer
- path: _test\.go
text: "Error return value is not checked"
linters:
- errcheck
+2 -30
View File
@@ -17,17 +17,6 @@ Put new code in the package that owns the behavior rather than in a catch-all he
extracting shared logic over duplicating it, and prefer changing existing code over bolting a
local workaround onto it.
## Non-goals
Most of this codebase's scope is open; a short list is permanently closed. Read
[docs/non-goals.md](docs/non-goals.md) before proposing or implementing in those areas.
**Live TV, OTA/DVB tuners, IPTV, EPG/XMLTV, DVR, and `.strm` remote-URL shortcuts will not be
accepted** — not in core, not as a plugin, not in a client. The first-party clients ship on the
Apple and Google stores, and a server that plays arbitrary remote stream URLs puts the whole
client suite at risk. This is settled product direction, not a design problem to solve; do not
write code for it, and say so plainly if asked.
## Gotchas
**Migrations.** New DB changes are Goose SQL migrations in `migrations/sql/`, created with
@@ -71,30 +60,18 @@ SDK, in the catalog, or in a specific plugin repo.
## Building and verifying
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `make test`, `make migrate-status`
/ `make migrate-up` — read the `Makefile` for the rest. Local services:
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `make migrate-status` /
`make migrate-up` — read the `Makefile` for the rest. Local services:
`docker compose up -d postgres redis`.
`make test-go` runs the whole Go suite. A Go test that cannot pass yet carries a `t.Skip` and the
reason in its own source, not an entry in a Makefile variable. `make test-web` still skips the
files in `WEBTEST_KNOWN_FAILURES`, which predate the CI gate; that list may only shrink — delete an
entry together with its fix, and never add to it to make a new change pass.
Before opening a merge request:
```bash
make lint
make test
cd web && pnpm run lint && pnpm run format:check
make verify-local-paths
```
`.github/workflows/ci.yml` runs these on every pull request, with one difference worth knowing:
`make lint` runs `golangci-lint` over the whole tree, while CI runs it with `--new-from-merge-base`
so only the lines a branch touched have to be clean. The repo does not pass a full run today, so
expect local output to include findings that are not yours and that CI will not fail on. Do not add
to them.
Go stays `gofmt`/`goimports` clean; the frontend follows `web/.prettierrc`.
## Skills
@@ -118,11 +95,6 @@ Additive-only within `/api/v1`:
- New features expose capability endpoints for feature detection rather than relying on version
sniffing. Contract strategy and tooling: issue #135.
Treat this as binding. The one exception: `/api/v1` is not locked yet, so a removal taken before
lock is in scope — but only when it is recorded in the pre-lock removals table in
[docs/architecture/v1-scope.md](docs/architecture/v1-scope.md) and ships before the lock. Assume
any removal not listed there is a mistake.
## Pull requests
Conventional Commit subjects (`feat(playback): add realtime session hub`). One concern per PR.
-4
View File
@@ -32,10 +32,6 @@ COPY --from=frontend_dist / web/dist
COPY cmd/ cmd/
COPY internal/ internal/
COPY migrations/ migrations/
# The settings contract is a Go package (contracts/settings/v1) that embeds the
# manifest, so the binary carries the exact bytes it was built from. It lives
# outside internal/ because clients vendor these files.
COPY contracts/ contracts/
ARG BUILD_REVISION
ARG BUILD_DIRTY=false
RUN --mount=type=cache,target=/root/.cache/go-build \
-3
View File
@@ -29,9 +29,6 @@ COPY --from=frontend /app/web/dist web/dist
COPY cmd/ cmd/
COPY internal/ internal/
COPY migrations/ migrations/
# See Dockerfile: the settings contract is an embedded Go package outside
# internal/, so the build fails without it.
COPY contracts/ contracts/
# Stage 3: Build Go binary for dev using a local plugin SDK checkout passed via
# BuildKit named context `silo_plugin_sdk`.
+1 -109
View File
@@ -1,4 +1,4 @@
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint test test-go test-web embed-stub clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up migrate-down-to settings-bindings verify-settings-bindings verify-settings-bindings-web verify-settings-bindings-all
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null))
MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..))
@@ -54,97 +54,6 @@ lint:
golangci-lint run
cd web && pnpm run lint
# Frontend test files that fail on main today. This list is shrink-only: delete
# an entry along with its fix, and never extend it to land a change. The Go
# suite has no equivalent — a Go test that cannot pass yet carries a t.Skip and
# its reason in the source, where whoever reads the test finds it.
WEBTEST_KNOWN_FAILURES := \
--exclude src/pages/Catalog.test.tsx \
--exclude src/pages/ItemDetail/SeasonContent.test.tsx \
--exclude src/pages/LibraryRecommended.test.tsx \
--exclude src/pages/audiobooks/player/useAudiobookPlayback.test.ts \
--exclude src/pages/setup-wizard/steps/ServerStorageStep.test.tsx \
--exclude src/player/hooks/useASSSubtitles.test.tsx
# The Go binary embeds the built frontend, so every Go build and test needs
# web/dist to exist. Tests never serve it, so a placeholder is enough; `make
# build` still builds the real bundle.
embed-stub:
@mkdir -p web/dist
@[ -e web/dist/index.html ] || printf '<!doctype html>\n' > web/dist/index.html
# Run the Go and frontend test suites.
test: test-go test-web
test-go: embed-stub
go test ./...
test-web:
cd web && pnpm exec vitest run $(WEBTEST_KNOWN_FAILURES)
# Regenerate the settings-contract bindings for every language.
#
# The client repos are siblings of this one (see CLAUDE.md); a missing checkout
# is skipped rather than failing, so a server-only developer can still run this.
#
# The conformance fixture (contracts/settings/v1/conformance.json) travels with
# the bindings: the vendored copy in web/src/lib is what the web runner reads.
# The Kotlin and Swift copies land together with their runners in the client
# repos, which will pick their own test-resource paths.
SILO_ANDROID_DIR ?= $(abspath ../silo-android)
SILO_APPLE_DIR ?= $(abspath ../silo-apple)
settings-bindings:
@mkdir -p internal/settingskeys
go run ./cmd/settingsgen -lang go -out internal/settingskeys/keys.go
gofmt -w internal/settingskeys/keys.go
go run ./cmd/settingsgen -lang ts -out web/src/lib/settingsContract.ts
@cd web && pnpm exec prettier --write src/lib/settingsContract.ts >/dev/null
cp contracts/settings/v1/conformance.json web/src/lib/settingsConformance.json
@if [ -d "$(SILO_ANDROID_DIR)" ]; then \
go run ./cmd/settingsgen -lang kotlin \
-out "$(SILO_ANDROID_DIR)/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt"; \
echo "wrote Kotlin bindings to $(SILO_ANDROID_DIR)"; \
else \
echo "skipping Kotlin: $(SILO_ANDROID_DIR) not checked out"; \
fi
@if [ -d "$(SILO_APPLE_DIR)" ]; then \
go run ./cmd/settingsgen -lang swift \
-out "$(SILO_APPLE_DIR)/iosApp/iosApp/Networking/SettingKeys.generated.swift"; \
echo "wrote Swift bindings to $(SILO_APPLE_DIR)"; \
else \
echo "skipping Swift: $(SILO_APPLE_DIR) not checked out"; \
fi
# Fail when the committed bindings disagree with the manifest, so a manifest
# change cannot merge without regenerating what every client reads.
#
# Split in two because the generated TypeScript is compared after prettier, and
# only the Web CI job has pnpm: the Go job runs this target, the Web job runs
# verify-settings-bindings-web. Locally, `verify-settings-bindings-all` is both.
verify-settings-bindings:
@CHECK_DIR=$$(mktemp -d) && trap 'rm -rf "$$CHECK_DIR"' EXIT && \
go run ./cmd/settingsgen -lang go | gofmt > "$$CHECK_DIR/keys.go" && \
diff -u internal/settingskeys/keys.go "$$CHECK_DIR/keys.go" \
|| { echo "::error::internal/settingskeys/keys.go is stale; run make settings-bindings"; exit 1; }
@diff -u web/src/lib/settingsConformance.json contracts/settings/v1/conformance.json \
|| { echo "::error::web/src/lib/settingsConformance.json is stale; run make settings-bindings"; exit 1; }
@echo "settings bindings are current"
# The half that needs pnpm: regenerate the web binding, format it the way the
# bindings target does, and compare. Without this a manifest change could merge
# with a stale settingsContract.ts, which is what every web control renders from.
verify-settings-bindings-web:
@CHECK_DIR=$$(mktemp -d) && trap 'rm -rf "$$CHECK_DIR"' EXIT && \
go run ./cmd/settingsgen -lang ts -out "$$CHECK_DIR/settingsContract.ts" && \
cd web && pnpm exec prettier --log-level silent --config .prettierrc \
--write "$$CHECK_DIR/settingsContract.ts" && cd .. && \
diff -u web/src/lib/settingsContract.ts "$$CHECK_DIR/settingsContract.ts" \
|| { echo "::error::web/src/lib/settingsContract.ts is stale; run make settings-bindings"; exit 1; }
@echo "web settings binding is current"
verify-settings-bindings-all: verify-settings-bindings verify-settings-bindings-web
# Check committed content for local machine path leaks.
verify-local-paths:
scripts/check-local-path-leaks.sh
@@ -162,23 +71,6 @@ migrate-validate:
migrate-status:
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-status
# Roll back every migration newer than VERSION (the version to KEEP).
#
# Not a routine operation: it discards data. It exists because some migrations
# are Go rather than SQL — the settings backfill and the jellycompat
# DisplayPreferences move — and those are registered in-process, so the goose
# CLI above cannot see or reverse them.
#
# This is a RANGE, not a list: everything newer than VERSION comes off, including
# migrations belonging to other features that happen to sort in between. Check
# `make migrate-status` and read the down of each one you are about to revert.
# Take a backup first regardless; the per-user SQLite stores have no down path.
#
# Usage: make migrate-down-to VERSION=<timestamp from migrate-status>
migrate-down-to:
@if [ -z "$(VERSION)" ]; then echo "usage: make migrate-down-to VERSION=<timestamp from make migrate-status>"; exit 1; fi
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-down-to "$(VERSION)"
# Apply pending Goose migrations through Silo's bootstrapping runner.
migrate-up:
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-only
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

-627
View File
@@ -1,627 +0,0 @@
// Command settingsgen emits typed bindings for the settings contract.
//
// One generator for every language rather than one per repo: the whole point of
// the contract is that four codebases agree on keys, types, scopes and
// defaults, and four independently-written generators would be four chances to
// disagree. Each client repo vendors the manifest and runs this to regenerate.
//
// Usage:
//
// settingsgen -lang go -out internal/settingskeys/keys.go
// settingsgen -lang ts -out web/src/lib/settingsContract.ts
// settingsgen -lang kotlin -out <path> -package org.siloserver.silo.model.settings
// settingsgen -lang swift -out <path>
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strings"
"github.com/Silo-Server/silo-server/internal/settingscontract"
)
func main() {
lang := flag.String("lang", "", "go, ts, kotlin or swift")
out := flag.String("out", "", "file to write (default stdout)")
pkg := flag.String("package", "", "package or namespace for the generated code")
flag.Parse()
contract, err := settingscontract.Load()
if err != nil {
fail("loading contract: %v", err)
}
var body []byte
switch *lang {
case "go":
body, err = generateGo(contract, defaultString(*pkg, "settingskeys"))
case "ts":
body, err = generateTypeScript(contract)
case "kotlin":
body, err = generateKotlin(contract,
defaultString(*pkg, "org.siloserver.silo.model.settings"))
case "swift":
body, err = generateSwift(contract)
default:
fail("unknown -lang %q: want go, ts, kotlin or swift", *lang)
}
if err != nil {
fail("generating %s: %v", *lang, err)
}
if *out == "" {
_, _ = os.Stdout.Write(body)
return
}
if err := os.WriteFile(*out, body, 0o644); err != nil { //nolint:gosec // generated source
fail("writing %s: %v", *out, err)
}
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "settingsgen: "+format+"\n", args...)
os.Exit(1)
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
// remoteAndLocal returns every definition, sorted by key so the output is
// stable: a generator whose output depends on manifest authoring order would
// produce spurious diffs on every unrelated manifest edit.
func sortedDefinitions(contract *settingscontract.Manifest) []*settingscontract.Definition {
defs := make([]*settingscontract.Definition, 0, len(contract.Definitions))
for i := range contract.Definitions {
defs = append(defs, &contract.Definitions[i])
}
sort.Slice(defs, func(i, j int) bool { return defs[i].Key < defs[j].Key })
return defs
}
// identifierFor turns a dotted key into a language identifier:
// playback.subtitle_language becomes PlaybackSubtitleLanguage.
func identifierFor(key string) string {
var out strings.Builder
for _, part := range strings.FieldsFunc(key, func(r rune) bool {
return r == '.' || r == '_' || r == '-'
}) {
out.WriteString(strings.ToUpper(part[:1]))
out.WriteString(part[1:])
}
return out.String()
}
// screamingCase turns a dotted key into PLAYBACK_SUBTITLE_LANGUAGE.
func screamingCase(key string) string {
replaced := strings.NewReplacer(".", "_", "-", "_").Replace(key)
return strings.ToUpper(replaced)
}
const generatedHeader = `Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT.
Regenerate with: make settings-bindings
Every key, type, scope and default here comes from the manifest, so a client
cannot drift from the server's contract by editing a constant. Adding a setting
is a manifest change plus a regeneration, never a hand-written key.`
func generateGo(contract *settingscontract.Manifest, pkg string) ([]byte, error) {
var out bytes.Buffer
for _, line := range strings.Split(generatedHeader, "\n") {
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
}
fmt.Fprintf(&out, "\npackage %s\n\n", pkg)
fmt.Fprintf(&out, "// Revision is the manifest revision these bindings were generated from.\nconst Revision = %d\n\n",
contract.Revision)
out.WriteString("// Setting keys, one constant per definition.\nconst (\n")
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, "\t// %s\n", def.Label)
fmt.Fprintf(&out, "\t%s = %q\n", identifierFor(def.Key), def.Key)
}
out.WriteString(")\n\n")
out.WriteString("// Remote lists every key the server stores.\nvar Remote = []string{\n")
for _, def := range sortedDefinitions(contract) {
if def.IsRemote() {
fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key))
}
}
out.WriteString("}\n\n")
out.WriteString("// ClientLocal lists keys the contract defines but the server never stores.\n")
out.WriteString("var ClientLocal = []string{\n")
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key))
}
}
out.WriteString("}\n")
return out.Bytes(), nil
}
func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
var out bytes.Buffer
out.WriteString("/**\n")
for _, line := range strings.Split(generatedHeader, "\n") {
out.WriteString(strings.TrimRight(" * "+line, " ") + "\n")
}
out.WriteString(" */\n\n")
fmt.Fprintf(&out, "export const SETTINGS_REVISION = %d;\n\n", contract.Revision)
out.WriteString("export interface SettingSuggestedOption {\n")
out.WriteString(" value: string;\n")
out.WriteString(" introducedIn: number;\n")
out.WriteString("}\n\n")
out.WriteString("export interface SettingOptionSet {\n")
out.WriteString(" type: string;\n")
out.WriteString(" options: readonly SettingSuggestedOption[];\n")
out.WriteString("}\n\n")
out.WriteString("export const SETTING_OPTION_SETS = {\n")
for _, name := range sortedOptionSetNames(contract) {
optionSet := contract.OptionSets[name]
fmt.Fprintf(&out, " %s: {\n", name)
fmt.Fprintf(&out, " type: %q,\n", optionSet.Type)
out.WriteString(" options: [\n")
for _, option := range optionSet.Options {
fmt.Fprintf(&out, " { value: %q, introducedIn: %d },\n",
option.Value, option.IntroducedIn)
}
out.WriteString(" ],\n")
out.WriteString(" },\n")
}
out.WriteString("} as const satisfies Record<string, SettingOptionSet>;\n\n")
out.WriteString("export type SettingOptionSetId = keyof typeof SETTING_OPTION_SETS;\n\n")
out.WriteString("export const SETTING_KEYS = {\n")
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, " /** %s */\n", def.Label)
fmt.Fprintf(&out, " %s: %q,\n", screamingCase(def.Key), def.Key)
}
out.WriteString("} as const;\n\n")
out.WriteString("export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];\n\n")
// The full definition table, so the UI can render controls from the
// contract rather than a hand-kept parallel manifest.
out.WriteString("export interface SettingDefinition {\n")
out.WriteString(" key: SettingKey;\n")
out.WriteString(" type: string;\n")
out.WriteString(" nullable: boolean;\n")
out.WriteString(" persistence: \"remote\" | \"client_local\";\n")
out.WriteString(" /** The manifest revision this definition first appeared in. A client\n")
out.WriteString(" * pinned to a newer contract than the server's advertised revision must\n")
out.WriteString(" * hide definitions, scopes, enum members and widened bounds introduced\n")
out.WriteString(" * after that revision — the server would reject them. */\n")
out.WriteString(" introducedIn: number;\n")
out.WriteString(" scopes: readonly string[];\n")
out.WriteString(" /** Revision each scope became writable at, aligned with scopes. */\n")
out.WriteString(" scopeIntroducedIn: readonly number[];\n")
out.WriteString(" resolutionOrder: readonly string[];\n")
out.WriteString(" defaultValue: unknown;\n")
out.WriteString(" label: string;\n")
out.WriteString(" description: string;\n")
out.WriteString(" category: string;\n")
out.WriteString(" control?: string;\n")
out.WriteString(" unit?: string;\n")
out.WriteString(" /** Advisory: platforms this setting is expected to matter on. Absent\n")
out.WriteString(" * means everywhere. A client hides settings for platforms it is not,\n")
out.WriteString(" * rather than disabling them without explanation. */\n")
out.WriteString(" platforms?: readonly string[];\n")
out.WriteString(" suggestedOptions?: SettingOptionSetId;\n")
out.WriteString(" unsetLabel?: string;\n")
out.WriteString(" values?: readonly { value: unknown; label: string; introducedIn: number }[];\n")
out.WriteString(" /** Present on enums whose members are ranked, so a ceiling or floor has a direction. */\n")
out.WriteString(" ordered?: boolean;\n")
out.WriteString(" minimum?: number;\n")
out.WriteString(" maximum?: number;\n")
out.WriteString(" /** Bound history, oldest first, when a bound was widened after revision 1;\n")
out.WriteString(" * a client filtering to an older server revision applies the newest entry\n")
out.WriteString(" * whose introducedIn does not exceed it. */\n")
out.WriteString(" minimumHistory?: readonly { value: number; introducedIn: number }[];\n")
out.WriteString(" maximumHistory?: readonly { value: number; introducedIn: number }[];\n")
out.WriteString(" step?: number;\n")
out.WriteString(" /** The policy input that narrows this setting, when the manifest binds one. */\n")
out.WriteString(" constrainedBy?: {\n")
out.WriteString(" policyInput: string;\n")
out.WriteString(" constraint: \"ceiling\" | \"floor\" | \"allowlist\" | \"locked\";\n")
out.WriteString(" };\n")
out.WriteString("}\n\n")
out.WriteString("export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {\n")
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, " %q: {\n", def.Key)
fmt.Fprintf(&out, " key: %q,\n", def.Key)
fmt.Fprintf(&out, " type: %q,\n", def.ValueSchema.Type)
fmt.Fprintf(&out, " nullable: %t,\n", def.ValueSchema.Nullable)
fmt.Fprintf(&out, " persistence: %q,\n", def.Persistence)
fmt.Fprintf(&out, " introducedIn: %d,\n", def.IntroducedIn)
fmt.Fprintf(&out, " scopes: [%s],\n", quotedScopes(def))
fmt.Fprintf(&out, " scopeIntroducedIn: [%s],\n", scopeRevisions(def))
fmt.Fprintf(&out, " resolutionOrder: [%s],\n", quotedResolution(def))
fmt.Fprintf(&out, " defaultValue: %s,\n", defaultLiteral(def))
fmt.Fprintf(&out, " label: %s,\n", jsString(def.Label))
fmt.Fprintf(&out, " description: %s,\n", jsString(def.Description))
fmt.Fprintf(&out, " category: %q,\n", def.Category)
if def.Control != "" {
fmt.Fprintf(&out, " control: %q,\n", def.Control)
}
if def.Unit != "" {
fmt.Fprintf(&out, " unit: %q,\n", def.Unit)
}
if len(def.Platforms) > 0 {
fmt.Fprintf(&out, " platforms: [%s],\n", quotedStrings(def.Platforms))
}
if def.SuggestedOptions != "" {
fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions)
}
if def.UnsetLabel != "" {
fmt.Fprintf(&out, " unsetLabel: %s,\n", jsString(def.UnsetLabel))
}
if len(def.ValueSchema.Values) > 0 {
out.WriteString(" values: [\n")
for _, member := range def.ValueSchema.Values {
encoded, err := json.Marshal(member.Value)
if err != nil {
return nil, err
}
fmt.Fprintf(&out, " { value: %s, label: %s, introducedIn: %d },\n",
encoded, jsString(member.Label), memberRevision(def, member))
}
out.WriteString(" ],\n")
}
if def.ValueSchema.Ordered {
out.WriteString(" ordered: true,\n")
}
if minimum, ok := def.ValueSchema.Minimum.Current(); ok {
fmt.Fprintf(&out, " minimum: %s,\n", trimFloat(minimum))
if history := boundHistory(def, def.ValueSchema.Minimum); history != "" {
fmt.Fprintf(&out, " minimumHistory: [%s],\n", history)
}
}
if maximum, ok := def.ValueSchema.Maximum.Current(); ok {
fmt.Fprintf(&out, " maximum: %s,\n", trimFloat(maximum))
if history := boundHistory(def, def.ValueSchema.Maximum); history != "" {
fmt.Fprintf(&out, " maximumHistory: [%s],\n", history)
}
}
if def.ValueSchema.Step != nil {
fmt.Fprintf(&out, " step: %s,\n", trimFloat(*def.ValueSchema.Step))
}
if def.ConstrainedBy != nil {
fmt.Fprintf(&out, " constrainedBy: { policyInput: %q, constraint: %q },\n",
def.ConstrainedBy.PolicyInput, def.ConstrainedBy.Constraint)
}
out.WriteString(" },\n")
}
out.WriteString("};\n")
return out.Bytes(), nil
}
func generateKotlin(contract *settingscontract.Manifest, pkg string) ([]byte, error) {
var out bytes.Buffer
for _, line := range strings.Split(generatedHeader, "\n") {
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
}
fmt.Fprintf(&out, "\npackage %s\n\n", pkg)
out.WriteString("data class SettingSuggestedOption(\n")
out.WriteString(" val value: String,\n")
out.WriteString(" val introducedIn: Int,\n")
out.WriteString(")\n\n")
out.WriteString("data class SettingOptionSet(\n")
out.WriteString(" val type: String,\n")
out.WriteString(" val options: List<SettingSuggestedOption>,\n")
out.WriteString(")\n\n")
out.WriteString("data class SettingPresentation(\n")
out.WriteString(" val suggestedOptions: String? = null,\n")
out.WriteString(" val unsetLabel: String? = null,\n")
out.WriteString(")\n\n")
out.WriteString("object SettingKeys {\n")
fmt.Fprintf(&out, " const val REVISION = %d\n\n", contract.Revision)
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, " /** %s */\n", def.Label)
fmt.Fprintf(&out, " const val %s = %q\n", screamingCase(def.Key), def.Key)
}
// The allowlist Android maintained by hand, generated instead. The whole
// class of "wrote a local key to the server" bug is a manifest question now.
out.WriteString("\n /** Every key the server stores. Safe to flush. */\n")
out.WriteString(" val REMOTE: List<String> = listOf(\n")
for _, def := range sortedDefinitions(contract) {
if def.IsRemote() {
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
}
}
out.WriteString(" )\n\n")
out.WriteString(" /** Contract-known keys that never leave the device. */\n")
out.WriteString(" val CLIENT_LOCAL: List<String> = listOf(\n")
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
}
}
out.WriteString(" )\n")
// Type classification, which Android kept as a second hand-maintained table
// that had to agree with the first.
for _, group := range []struct {
name string
types []settingscontract.ValueType
}{
{"BOOLEAN_KEYS", []settingscontract.ValueType{settingscontract.TypeBoolean}},
{"INT_KEYS", []settingscontract.ValueType{settingscontract.TypeInteger}},
{"DOUBLE_KEYS", []settingscontract.ValueType{settingscontract.TypeNumber}},
} {
// Remote only: these tables drive how a value read back from the
// server is parsed, and a client_local key never comes back from the
// server at all. Listing one would describe a wire format that has no
// wire.
fmt.Fprintf(&out, "\n val %s: Set<String> = setOf(\n", group.name)
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
continue
}
for _, want := range group.types {
if def.ValueSchema.Type == want {
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
}
}
}
out.WriteString(" )\n")
}
out.WriteString("}\n\n")
out.WriteString("object SettingPresentationMetadata {\n")
out.WriteString(" val OPTION_SETS: Map<String, SettingOptionSet> = mapOf(\n")
for _, name := range sortedOptionSetNames(contract) {
optionSet := contract.OptionSets[name]
fmt.Fprintf(&out, " %q to SettingOptionSet(\n", name)
fmt.Fprintf(&out, " type = %q,\n", optionSet.Type)
out.WriteString(" options = listOf(\n")
for _, option := range optionSet.Options {
fmt.Fprintf(&out, " SettingSuggestedOption(%q, %d),\n",
option.Value, option.IntroducedIn)
}
out.WriteString(" ),\n")
out.WriteString(" ),\n")
}
out.WriteString(" )\n\n")
out.WriteString(" val DEFINITIONS: Map<String, SettingPresentation> = mapOf(\n")
for _, def := range sortedDefinitions(contract) {
if def.SuggestedOptions == "" && def.UnsetLabel == "" {
continue
}
fmt.Fprintf(&out, " SettingKeys.%s to SettingPresentation(\n", screamingCase(def.Key))
if def.SuggestedOptions != "" {
fmt.Fprintf(&out, " suggestedOptions = %q,\n", def.SuggestedOptions)
}
if def.UnsetLabel != "" {
fmt.Fprintf(&out, " unsetLabel = %q,\n", def.UnsetLabel)
}
out.WriteString(" ),\n")
}
out.WriteString(" )\n\n")
out.WriteString(" fun suggestedValues(key: String, revision: Int = SettingKeys.REVISION): List<String> {\n")
out.WriteString(" val setId = DEFINITIONS[key]?.suggestedOptions ?: return emptyList()\n")
out.WriteString(" return OPTION_SETS[setId]?.options\n")
out.WriteString(" ?.filter { it.introducedIn <= revision }\n")
out.WriteString(" ?.map { it.value }\n")
out.WriteString(" .orEmpty()\n")
out.WriteString(" }\n")
out.WriteString("}\n")
return out.Bytes(), nil
}
func generateSwift(contract *settingscontract.Manifest) ([]byte, error) {
var out bytes.Buffer
for _, line := range strings.Split(generatedHeader, "\n") {
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
}
out.WriteString("\nimport Foundation\n\n")
out.WriteString("public struct SettingSuggestedOption: Hashable, Sendable {\n")
out.WriteString(" public let value: String\n")
out.WriteString(" public let introducedIn: Int\n")
out.WriteString("}\n\n")
out.WriteString("public struct SettingOptionSet: Hashable, Sendable {\n")
out.WriteString(" public let type: String\n")
out.WriteString(" public let options: [SettingSuggestedOption]\n")
out.WriteString("}\n\n")
out.WriteString("public struct SettingPresentation: Hashable, Sendable {\n")
out.WriteString(" public let suggestedOptions: String?\n")
out.WriteString(" public let unsetLabel: String?\n")
out.WriteString("}\n\n")
out.WriteString("/// Every setting the contract defines.\n")
out.WriteString("public enum SettingKey: String, CaseIterable, Sendable {\n")
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, " /// %s\n", def.Label)
fmt.Fprintf(&out, " case %s = %q\n", lowerFirst(identifierFor(def.Key)), def.Key)
}
out.WriteString("}\n\n")
out.WriteString("public extension SettingKey {\n")
fmt.Fprintf(&out, " static let revision = %d\n\n", contract.Revision)
out.WriteString(" /// Keys the server stores. The rest never leave the device.\n")
out.WriteString(" static let remote: [SettingKey] = [\n")
for _, def := range sortedDefinitions(contract) {
if def.IsRemote() {
fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key)))
}
}
out.WriteString(" ]\n\n")
out.WriteString(" static let clientLocal: [SettingKey] = [\n")
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key)))
}
}
out.WriteString(" ]\n")
out.WriteString("}\n\n")
out.WriteString("public enum SettingPresentationMetadata {\n")
out.WriteString(" public static let optionSets: [String: SettingOptionSet] = [\n")
for _, name := range sortedOptionSetNames(contract) {
optionSet := contract.OptionSets[name]
fmt.Fprintf(&out, " %q: SettingOptionSet(\n", name)
fmt.Fprintf(&out, " type: %q,\n", optionSet.Type)
out.WriteString(" options: [\n")
for _, option := range optionSet.Options {
fmt.Fprintf(&out, " SettingSuggestedOption(value: %q, introducedIn: %d),\n",
option.Value, option.IntroducedIn)
}
out.WriteString(" ]\n")
out.WriteString(" ),\n")
}
out.WriteString(" ]\n\n")
out.WriteString(" public static let definitions: [SettingKey: SettingPresentation] = [\n")
for _, def := range sortedDefinitions(contract) {
if def.SuggestedOptions == "" && def.UnsetLabel == "" {
continue
}
fmt.Fprintf(&out, " .%s: SettingPresentation(\n", lowerFirst(identifierFor(def.Key)))
if def.SuggestedOptions != "" {
fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions)
} else {
out.WriteString(" suggestedOptions: nil,\n")
}
if def.UnsetLabel != "" {
fmt.Fprintf(&out, " unsetLabel: %q\n", def.UnsetLabel)
} else {
out.WriteString(" unsetLabel: nil\n")
}
out.WriteString(" ),\n")
}
out.WriteString(" ]\n\n")
out.WriteString(" public static func suggestedValues(\n")
out.WriteString(" for key: SettingKey,\n")
out.WriteString(" revision: Int = SettingKey.revision\n")
out.WriteString(" ) -> [String] {\n")
out.WriteString(" guard let setID = definitions[key]?.suggestedOptions,\n")
out.WriteString(" let optionSet = optionSets[setID] else { return [] }\n")
out.WriteString(" return optionSet.options\n")
out.WriteString(" .filter { $0.introducedIn <= revision }\n")
out.WriteString(" .map(\\.value)\n")
out.WriteString(" }\n")
out.WriteString("}\n")
return out.Bytes(), nil
}
func lowerFirst(value string) string {
if value == "" {
return value
}
return strings.ToLower(value[:1]) + value[1:]
}
func sortedOptionSetNames(contract *settingscontract.Manifest) []string {
names := make([]string, 0, len(contract.OptionSets))
for name := range contract.OptionSets {
names = append(names, name)
}
sort.Strings(names)
return names
}
func quotedStrings(values []string) string {
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, fmt.Sprintf("%q", value))
}
return strings.Join(parts, ", ")
}
func quotedScopes(def *settingscontract.Definition) string {
parts := make([]string, 0, len(def.AllowedScopes))
for _, entry := range def.AllowedScopes {
parts = append(parts, fmt.Sprintf("%q", entry.Scope))
}
return strings.Join(parts, ", ")
}
// scopeRevisions emits each scope's introduction revision, aligned with
// quotedScopes. A scope entry with no explicit tag has held since the
// definition itself appeared.
func scopeRevisions(def *settingscontract.Definition) string {
parts := make([]string, 0, len(def.AllowedScopes))
for _, entry := range def.AllowedScopes {
revision := entry.IntroducedIn
if revision == 0 {
revision = def.IntroducedIn
}
parts = append(parts, fmt.Sprintf("%d", revision))
}
return strings.Join(parts, ", ")
}
// memberRevision is the revision an enum member became a legal value at; an
// untagged member has existed since its definition.
func memberRevision(def *settingscontract.Definition, member settingscontract.EnumMember) int {
if member.IntroducedIn != 0 {
return member.IntroducedIn
}
return def.IntroducedIn
}
// boundHistory renders a widened bound's full history so an ahead-of-server
// client can recover the bound in force at an older revision. Empty when the
// bound never changed — the flattened minimum/maximum already carries it.
func boundHistory(def *settingscontract.Definition, bound *settingscontract.Bound) string {
if bound == nil || len(bound.History) < 2 {
return ""
}
parts := make([]string, 0, len(bound.History))
for _, entry := range bound.History {
revision := entry.IntroducedIn
if revision == 0 {
revision = def.IntroducedIn
}
parts = append(parts, fmt.Sprintf("{ value: %s, introducedIn: %d }",
trimFloat(entry.Value), revision))
}
return strings.Join(parts, ", ")
}
func quotedResolution(def *settingscontract.Definition) string {
parts := make([]string, 0, len(def.ResolutionOrder))
for _, scope := range def.ResolutionOrder {
parts = append(parts, fmt.Sprintf("%q", scope))
}
return strings.Join(parts, ", ")
}
func defaultLiteral(def *settingscontract.Definition) string {
if len(def.DefaultValue) == 0 {
return "null"
}
return string(bytes.TrimSpace(def.DefaultValue))
}
func jsString(value string) string {
encoded, err := json.Marshal(value)
if err != nil {
return `""`
}
return string(encoded)
}
func trimFloat(value float64) string {
return strings.TrimSuffix(fmt.Sprintf("%g", value), ".0")
}
-90
View File
@@ -1,90 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
)
func TestPresentationMetadataIsGeneratedForEveryClientLanguage(t *testing.T) {
contract, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
tests := []struct {
name string
generate func() ([]byte, error)
markers []string
}{
{
name: "typescript",
generate: func() ([]byte, error) { return generateTypeScript(contract) },
markers: []string{
"export const SETTING_OPTION_SETS",
`catalog_metadata_languages`,
`suggestedOptions: "playback_subtitle_languages"`,
`unsetLabel: "None"`,
// Advisory platform tags reach the web UI so it can hide
// settings that do not apply to the device being edited.
`platforms: ["web"]`,
`platforms: ["ios", "android"]`,
},
},
{
name: "kotlin",
generate: func() ([]byte, error) {
return generateKotlin(contract, "org.siloserver.silo.model.settings")
},
markers: []string{
"object SettingPresentationMetadata",
`"playback_audio_languages" to SettingOptionSet`,
`suggestedOptions = "catalog_metadata_languages"`,
`unsetLabel = "Library default"`,
},
},
{
name: "swift",
generate: func() ([]byte, error) { return generateSwift(contract) },
markers: []string{
"public enum SettingPresentationMetadata",
`"playback_subtitle_languages": SettingOptionSet`,
`suggestedOptions: "playback_audio_languages"`,
`unsetLabel: "No preference"`,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
generated, err := tc.generate()
if err != nil {
t.Fatalf("generating: %v", err)
}
for _, marker := range tc.markers {
if !strings.Contains(string(generated), marker) {
t.Errorf("generated output omitted %q", marker)
}
}
})
}
}
func TestGeneratedOptionOrderMatchesTheManifest(t *testing.T) {
contract, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
generated, err := generateTypeScript(contract)
if err != nil {
t.Fatalf("generating TypeScript: %v", err)
}
text := string(generated)
arabic := strings.Index(text, `{ value: "ar", introducedIn: 1 }`)
bengali := strings.Index(text, `{ value: "bn", introducedIn: 1 }`)
if arabic < 0 || bengali < 0 || arabic >= bengali {
t.Fatalf("generated option order does not preserve the manifest: ar=%d bn=%d", arabic, bengali)
}
}
+16 -73
View File
@@ -95,7 +95,6 @@ import (
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/server"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/taskmanager"
taskrepository "github.com/Silo-Server/silo-server/internal/taskmanager/repository"
@@ -308,16 +307,6 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
// still reads via the read-path pass-through, so a backfill error must never
// block boot. The sensitive-settings pass runs first so the arr
// resolve-then-encrypt pass sees consistent referenced settings.
// librarySettingsCleaner wires the per-user canonical settings cleanup the
// library delete job runs, or nil when the user store is unavailable — the
// executor treats a nil cleaner as "skip".
func librarySettingsCleaner(pool *pgxpool.Pool, stores userstore.UserStoreProvider) adminjob.LibrarySettingsCleaner {
if pool == nil || stores == nil {
return nil
}
return userstore.NewSettingValuesCleaner(auth.NewUserRepository(pool), stores)
}
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
settingsN, err := settings.BackfillSensitiveSettings(ctx)
if err != nil {
@@ -402,30 +391,10 @@ func main() {
envFile := flag.String("env", ".env", "path to .env bootstrap file")
migrateOnly := flag.Bool("migrate-only", false, "apply database migrations and exit")
migrateStatus := flag.Bool("migrate-status", false, "show database migration status and exit")
migrateDownTo := flag.Int64("migrate-down-to", -1,
"roll back every migration newer than this version and exit (the version to KEEP)")
flag.Parse()
ctx := context.Background()
// Step 0: Validate the embedded settings contract before anything can
// depend on it. A malformed or self-inconsistent manifest is a build defect,
// not a runtime condition, so failing here — loudly, before the first
// request — is the whole point: the alternative is shipping an image whose
// contract disagrees with the clients that vendored it.
contract, err := settingscontract.Load()
if err != nil {
log.Fatalf("settings contract: %v", err)
}
contractETag, err := settingscontract.ETag()
if err != nil {
log.Fatalf("settings contract: %v", err)
}
slog.Info("settings contract loaded",
"revision", contract.Revision,
"definitions", len(contract.Definitions),
"etag", contractETag)
// Step 1: Bootstrap from .env
bc, err := config.LoadBootstrap(*envFile)
if err != nil {
@@ -475,21 +444,6 @@ func main() {
return
}
if *migrateDownTo >= 0 {
// Deliberately its own flag rather than a mode of --migrate-only: this
// discards data, and several of the migrations it reverses are Go ones
// the goose CLI cannot reach, so it is the only way to undo them
// short of restoring a backup.
migCtx, migCancel := database.MigrationContext(ctx)
migErr := database.MigrateDownTo(migCtx, pool, migrations.FS, "sql", *migrateDownTo)
migCancel()
if migErr != nil {
log.Fatalf("failed to roll back migrations: %v", migErr)
}
slog.Info("database migrations rolled back", "kept_through_version", *migrateDownTo)
return
}
if *migrateOnly {
migCtx, migCancel := database.MigrationContext(ctx)
migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql")
@@ -975,7 +929,7 @@ func main() {
s.SetLiteraryWorkLinker(literaryWorkService)
s.SetEbookEnrichmentQueue(ebooks.NewEnrichmentQueue(deps.DB))
deps.Scanner = s
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, cfg.Playback.FFmpegPath, 10*time.Second)
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
slog.Info("scanner initialized")
}
@@ -2013,6 +1967,9 @@ func main() {
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
}
if deps.FolderRepo != nil && deps.Scanner != nil {
taskMgr.Register(tasks.NewVerifyFilePresenceTask(deps.FolderRepo, deps.Scanner, 0))
}
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
taskMgr.Register(tasks.NewBackfillMediaItemAliasesTask(catalog.NewItemAliasRepository(deps.DB)))
if deps.S3Public != nil {
@@ -2086,11 +2043,6 @@ func main() {
taskMgr.Register(tasks.NewRebuildReleaseInterestTask(notificationSystem))
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationSystem))
}
if userStoreProvider != nil {
taskMgr.Register(tasks.NewSettingMutationsRetentionTask(userstore.NewSettingMutationSweeper(
auth.NewUserRepository(deps.DB), userStoreProvider,
)))
}
if matchWorker != nil {
taskMgr.Register(tasks.NewMatchMediaTask(matchWorker))
}
@@ -2258,6 +2210,9 @@ func main() {
if deps.ImageResolver != nil {
absDetailSvc.SetImageResolver(deps.ImageResolver)
}
if deps.FileRepo != nil {
absDetailSvc.SetMissingFileMarker(deps.FileRepo)
}
var absScopeResolver scopeResolver
if policySystem != nil {
absScopeResolver = policy.NewViewerResolver(absUserRepo, userStoreProvider, nil, policySystem.PDP(), accessGroupStore)
@@ -2477,8 +2432,7 @@ func main() {
deps.S3Private,
itemRefreshExecutor,
libraryRefreshExecutor,
adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo,
librarySettingsCleaner(deps.DB, userStoreProvider)),
adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo),
adminjob.NewImageCacheCleanupExecutor(deps.S3Public),
templateBundleApplyExecutor,
deps.RealtimeHub,
@@ -2551,6 +2505,9 @@ func main() {
detailSvc.SetGroupClaimRepository(catalog.NewGroupClaimRepository(deps.DB))
detailSvc.SetProbeEnsurer(deps.ProbeEnsurer)
detailSvc.SetChapterThumbnailQueuer(deps.ChapterThumbnailQueuer)
if deps.FileRepo != nil {
detailSvc.SetMissingFileMarker(deps.FileRepo)
}
if deps.ImageResolver != nil {
detailSvc.SetImageResolver(deps.ImageResolver)
}
@@ -3308,29 +3265,15 @@ type scopeEntitlementResolver struct {
}
func (r scopeEntitlementResolver) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) {
scope, err := r.resolveScope(ctx, userID, profileID)
if err != nil {
return "", err
}
return scope.MaxPlaybackQuality, nil
}
// MaxContentRating implements mediarequests.ContentRatingResolver so request
// discovery honors the profile's parental rating ceiling.
func (r scopeEntitlementResolver) MaxContentRating(ctx context.Context, userID int, profileID string) (string, error) {
scope, err := r.resolveScope(ctx, userID, profileID)
if err != nil {
return "", err
}
return scope.MaxContentRating, nil
}
func (r scopeEntitlementResolver) resolveScope(ctx context.Context, userID int, profileID string) (access.Scope, error) {
return r.resolver.Resolve(ctx, access.ResolveInput{
scope, err := r.resolver.Resolve(ctx, access.ResolveInput{
UserID: userID,
ProfileID: profileID,
SkipPINVerification: true,
})
if err != nil {
return "", err
}
return scope.MaxPlaybackQuality, nil
}
// audiobooksSettingsAdapter bridges catalog.ServerSettingsRepo (which
-1
View File
@@ -40,6 +40,5 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
PositionSeconds: s.Position,
IsPaused: s.IsPaused,
HasWebSocket: s.HasWebSocket,
IsJellyfinCompat: s.IsJellyfinCompat,
}
}
-598
View File
@@ -1,598 +0,0 @@
{
"fixture_version": 1,
"manifest_revision": 4,
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
"cases": [
{
"name": "resolution_order_series_wins",
"description": "The full ladder: with values stored at every scope a content key allows, profile_series beats profile_library, profile_device, and profile.",
"keys": ["playback.subtitle_language"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "de"
},
{
"key": "playback.subtitle_language",
"scope": "profile_library",
"profile_id": "p1",
"library_id": 7,
"value": "fr"
},
{
"key": "playback.subtitle_language",
"scope": "profile_series",
"profile_id": "p1",
"series_id": "s-101",
"value": "ja"
}
],
"expected": [
{ "key": "playback.subtitle_language", "value": "ja", "source": "profile_series" }
]
},
{
"name": "resolution_order_library_beats_device",
"description": "Without a series row, the library row wins over the device and profile rows.",
"keys": ["playback.subtitle_language"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "de"
},
{
"key": "playback.subtitle_language",
"scope": "profile_library",
"profile_id": "p1",
"library_id": 7,
"value": "fr"
}
],
"expected": [
{ "key": "playback.subtitle_language", "value": "fr", "source": "profile_library" }
]
},
{
"name": "resolution_order_device_beats_profile",
"description": "Without content rows, the device override wins over the profile fallback even though the context names a library and a series.",
"keys": ["playback.subtitle_language"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "de"
}
],
"expected": [
{ "key": "playback.subtitle_language", "value": "de", "source": "profile_device" }
]
},
{
"name": "resolution_order_profile_alone",
"description": "A profile row alone resolves at profile scope.",
"keys": ["playback.subtitle_language"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }
],
"expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }]
},
{
"name": "device_override_beats_profile_for_quality",
"description": "playback.preferred_quality has no content scopes; its device override wins over the profile value.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "720p"
},
{
"key": "playback.preferred_quality",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "1080p"
}
],
"expected": [
{ "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" }
]
},
{
"name": "missing_device_identity_drops_device_scope",
"description": "A caller with no device identity must not see a device override; the profile row answers instead. This is the anonymous jellycompat seed: a device row leaking here hands one device's settings to every client.",
"keys": ["playback.subtitle_language"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "de"
}
],
"expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }]
},
{
"name": "foreign_identity_rows_never_resolve",
"description": "Rows for another profile, another device, or another series must not resolve just because a batched read returned them; the answer falls to the contract default.",
"keys": ["playback.subtitle_language"],
"context": { "profile_id": "p1", "device_id": "d1", "series_ids": ["s-101"] },
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p2", "value": "xx" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d2",
"value": "yy"
},
{
"key": "playback.subtitle_language",
"scope": "profile_series",
"profile_id": "p1",
"series_id": "s-other",
"value": "zz"
}
],
"expected": [{ "key": "playback.subtitle_language", "value": null, "source": "default" }]
},
{
"name": "absent_values_resolve_to_contract_defaults",
"description": "Nothing stored resolves to each definition's default_value with source \"default\": enum, boolean, integer, and nullable language tag.",
"keys": [
"playback.subtitle_mode",
"playback.show_forced_subtitles",
"playback.next_up_prompt_seconds",
"playback.audio_language"
],
"context": { "profile_id": "p1", "device_id": "d1" },
"expected": [
{ "key": "playback.subtitle_mode", "value": "auto", "source": "default" },
{ "key": "playback.show_forced_subtitles", "value": true, "source": "default" },
{ "key": "playback.next_up_prompt_seconds", "value": 30, "source": "default" },
{ "key": "playback.audio_language", "value": null, "source": "default" }
]
},
{
"name": "batch_resolves_each_key_independently",
"description": "One batch, three keys, three different sources: a device override, a profile value, and a default.",
"keys": [
"playback.preferred_quality",
"playback.subtitle_mode",
"playback.audio_language"
],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "1080p"
},
{ "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "always" }
],
"expected": [
{ "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" },
{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" },
{ "key": "playback.audio_language", "value": null, "source": "default" }
]
},
{
"name": "ceiling_caps_stored_quality_and_reports_the_stored_value",
"description": "The manifest binds playback.preferred_quality to the max_playback_quality ceiling. A stored 2160p over a 1080p cap resolves to 1080p while the authored value survives, reported as stored_value with constrained:true.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "2160p"
}
],
"constraints": { "max_playback_quality": "1080p" },
"expected": [
{
"key": "playback.preferred_quality",
"value": "1080p",
"source": "profile",
"constrained": true,
"stored_value": "2160p",
"constraint_kind": "ceiling"
}
]
},
{
"name": "ceiling_leaves_quality_under_the_cap_alone",
"description": "A value at or under the cap passes through untouched and is not reported as constrained.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "720p"
}
],
"constraints": { "max_playback_quality": "1080p" },
"expected": [{ "key": "playback.preferred_quality", "value": "720p", "source": "profile" }]
},
{
"name": "ceiling_ranks_auto_below_every_cap",
"description": "The ordered enum lists \"auto\" first because it never exceeds a cap: even the lowest cap leaves it alone.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "auto"
}
],
"constraints": { "max_playback_quality": "480p" },
"expected": [{ "key": "playback.preferred_quality", "value": "auto", "source": "profile" }]
},
{
"name": "ceiling_caps_original_as_the_highest_member",
"description": "\"original\" is the uncapped source and ranks above every resolution, so any cap brings it down.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "original"
}
],
"constraints": { "max_playback_quality": "2160p" },
"expected": [
{
"key": "playback.preferred_quality",
"value": "2160p",
"source": "profile",
"constrained": true,
"stored_value": "original",
"constraint_kind": "ceiling"
}
]
},
{
"name": "null_bitrate_is_unbounded_and_a_ceiling_caps_it",
"description": "null on the nullable integer playback.max_bitrate_kbps means \"no cap of my own\", which is unbounded above. It has no numeric rank, so a resolver that compares it as equal lets the one value that most needs capping slip past; a ceiling must bring it down to the limit.",
"keys": ["playback.max_bitrate_kbps"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null }
],
"constraint_bindings": [
{
"key": "playback.max_bitrate_kbps",
"policy_input": "max_bitrate_kbps",
"constraint": "ceiling"
}
],
"constraints": { "max_bitrate_kbps": 8000 },
"expected": [
{
"key": "playback.max_bitrate_kbps",
"value": 8000,
"source": "profile",
"constrained": true,
"stored_value": null,
"constraint_kind": "ceiling"
}
]
},
{
"name": "default_null_bitrate_is_capped_by_a_ceiling",
"description": "The contract default for playback.max_bitrate_kbps is null, so even with nothing stored a ceiling caps the resolved default; source stays \"default\" and the null is reported as stored_value.",
"keys": ["playback.max_bitrate_kbps"],
"context": { "profile_id": "p1" },
"constraint_bindings": [
{
"key": "playback.max_bitrate_kbps",
"policy_input": "max_bitrate_kbps",
"constraint": "ceiling"
}
],
"constraints": { "max_bitrate_kbps": 8000 },
"expected": [
{
"key": "playback.max_bitrate_kbps",
"value": 8000,
"source": "default",
"constrained": true,
"stored_value": null,
"constraint_kind": "ceiling"
}
]
},
{
"name": "floor_leaves_an_unbounded_bitrate_alone",
"description": "The mirror rule: unbounded already satisfies any floor, so a floor must not touch a null numeric.",
"keys": ["playback.max_bitrate_kbps"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null }
],
"constraint_bindings": [
{
"key": "playback.max_bitrate_kbps",
"policy_input": "min_bitrate_kbps",
"constraint": "floor"
}
],
"constraints": { "min_bitrate_kbps": 8000 },
"expected": [{ "key": "playback.max_bitrate_kbps", "value": null, "source": "profile" }]
},
{
"name": "allowlist_falls_back_when_the_default_is_outside_the_list",
"description": "With nothing stored, catalog.metadata_language resolves to its default null, which is outside the allowlist. The fallback is the first allowed member — not the definition default, which is exactly the value the policy forbids.",
"keys": ["catalog.metadata_language"],
"context": { "profile_id": "p1" },
"constraint_bindings": [
{
"key": "catalog.metadata_language",
"policy_input": "allowed_metadata_languages",
"constraint": "allowlist"
}
],
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
"expected": [
{
"key": "catalog.metadata_language",
"value": "en",
"source": "default",
"constrained": true,
"stored_value": null,
"constraint_kind": "allowlist"
}
]
},
{
"name": "allowlist_replaces_a_forbidden_choice",
"description": "A stored value outside the allowlist is replaced by the first allowed member, with the authored choice preserved as stored_value.",
"keys": ["catalog.metadata_language"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "ja" }
],
"constraint_bindings": [
{
"key": "catalog.metadata_language",
"policy_input": "allowed_metadata_languages",
"constraint": "allowlist"
}
],
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
"expected": [
{
"key": "catalog.metadata_language",
"value": "en",
"source": "profile",
"constrained": true,
"stored_value": "ja",
"constraint_kind": "allowlist"
}
]
},
{
"name": "allowlist_passes_a_permitted_choice",
"description": "A stored value inside the allowlist passes through untouched.",
"keys": ["catalog.metadata_language"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "fr" }
],
"constraint_bindings": [
{
"key": "catalog.metadata_language",
"policy_input": "allowed_metadata_languages",
"constraint": "allowlist"
}
],
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
"expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }]
},
{
"name": "metadata_language_exceptions_resolve_as_one_object",
"description": "The original-language exception map is a single profile-scoped value; resolution preserves every source-to-target entry together.",
"keys": ["catalog.metadata_language_overrides"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "catalog.metadata_language_overrides",
"scope": "profile",
"profile_id": "p1",
"value": { "ja": "en", "no": "x-silo-original" }
}
],
"expected": [
{
"key": "catalog.metadata_language_overrides",
"value": { "ja": "en", "no": "x-silo-original" },
"source": "profile"
}
]
},
{
"name": "locked_replaces_a_differing_choice",
"description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.",
"keys": ["playback.subtitle_mode"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "off" }
],
"constraint_bindings": [
{
"key": "playback.subtitle_mode",
"policy_input": "forced_subtitle_mode",
"constraint": "locked"
}
],
"constraints": { "forced_subtitle_mode": "always" },
"expected": [
{
"key": "playback.subtitle_mode",
"value": "always",
"source": "profile",
"constrained": true,
"stored_value": "off",
"constraint_kind": "locked"
}
]
},
{
"name": "locked_leaves_an_equal_value_unconstrained",
"description": "A stored value already equal to the lock is not a narrowing: it passes through with no constrained flag, so clients do not tell the user their own choice was overridden.",
"keys": ["playback.subtitle_mode"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.subtitle_mode",
"scope": "profile",
"profile_id": "p1",
"value": "always"
}
],
"constraint_bindings": [
{
"key": "playback.subtitle_mode",
"policy_input": "forced_subtitle_mode",
"constraint": "locked"
}
],
"constraints": { "forced_subtitle_mode": "always" },
"expected": [{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" }]
},
{
"name": "locked_replaces_the_contract_default",
"description": "With nothing stored, the lock replaces even the contract default: source stays \"default\" and the default is reported as stored_value, exactly like a capped default.",
"keys": ["playback.subtitle_mode"],
"context": { "profile_id": "p1" },
"constraint_bindings": [
{
"key": "playback.subtitle_mode",
"policy_input": "forced_subtitle_mode",
"constraint": "locked"
}
],
"constraints": { "forced_subtitle_mode": "always" },
"expected": [
{
"key": "playback.subtitle_mode",
"value": "always",
"source": "default",
"constrained": true,
"stored_value": "auto",
"constraint_kind": "locked"
}
]
},
{
"name": "subtitle_appearance_ignores_content_scopes",
"description": "playback.subtitle_appearance resolves profile_device then profile only. With a library and a series in the context, the device row still wins — and the sparse device object replaces the profile object outright rather than merging with it.",
"keys": ["playback.subtitle_appearance"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{
"key": "playback.subtitle_appearance",
"scope": "profile",
"profile_id": "p1",
"value": { "fontSize": "medium", "fontColor": "#ffcc00" }
},
{
"key": "playback.subtitle_appearance",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": { "fontSize": "xxlarge", "position": "top" }
}
],
"expected": [
{
"key": "playback.subtitle_appearance",
"value": { "fontSize": "xxlarge", "position": "top" },
"source": "profile_device"
}
]
},
{
"name": "subtitle_appearance_falls_to_profile_without_device",
"description": "Without a device identity the profile's appearance object answers, unmerged.",
"keys": ["playback.subtitle_appearance"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.subtitle_appearance",
"scope": "profile",
"profile_id": "p1",
"value": { "fontSize": "medium", "fontColor": "#ffcc00" }
},
{
"key": "playback.subtitle_appearance",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": { "fontSize": "xxlarge", "position": "top" }
}
],
"expected": [
{
"key": "playback.subtitle_appearance",
"value": { "fontSize": "medium", "fontColor": "#ffcc00" },
"source": "profile"
}
]
}
]
}
-18
View File
@@ -1,18 +0,0 @@
// Package settingsv1 embeds the canonical cross-platform user settings
// contract so the server binary carries the exact bytes it was built from.
//
// This package deliberately contains nothing but the embed directive. The
// contract files are the artifact clients vendor and generate bindings from, so
// they live at this stable path rather than inside an internal package; the
// embed has to sit beside them because go:embed cannot reach outside its own
// directory.
//
// Loading, validation, and lookup live in internal/settingscontract.
package settingsv1
import "embed"
// FS holds manifest.json, manifest.schema.json, and schemas/.
//
//go:embed manifest.json manifest.schema.json schemas
var FS embed.FS
File diff suppressed because it is too large Load Diff
-342
View File
@@ -1,342 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/manifest.schema.json",
"title": "Silo cross-platform user settings manifest",
"description": "Canonical contract for every production, user-facing setting. See docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md.",
"type": "object",
"additionalProperties": false,
"required": ["api_version", "revision", "definitions"],
"properties": {
"api_version": {
"description": "Settings protocol version. Changes only for a change no revision rule can express.",
"type": "integer",
"minimum": 1
},
"revision": {
"description": "Monotonically increasing integer bumped by every manifest PR.",
"type": "integer",
"minimum": 1
},
"option_sets": {
"description": "Advisory ordered vocabularies for open-value controls. They suggest values but never constrain the corresponding value schema.",
"type": "object",
"propertyNames": { "$ref": "#/$defs/optionSetName" },
"additionalProperties": { "$ref": "#/$defs/optionSet" }
},
"definitions": {
"type": "array",
"items": { "$ref": "#/$defs/definition" }
}
},
"$defs": {
"settingKey": {
"description": "Lowercase dot-separated identifier. Canonical names do not encode a platform.",
"type": "string",
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$",
"maxLength": 128
},
"revisionRef": {
"description": "Manifest revision in which this element was introduced.",
"type": "integer",
"minimum": 1
},
"optionSetName": {
"description": "Stable snake_case identifier for a reusable presentation vocabulary.",
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"maxLength": 128
},
"suggestedOption": {
"description": "One advisory value. Labels are localized by clients from the stable wire value.",
"type": "object",
"additionalProperties": false,
"required": ["value", "introduced_in"],
"properties": {
"value": { "type": "string", "minLength": 1 },
"introduced_in": { "$ref": "#/$defs/revisionRef" }
}
},
"optionSet": {
"description": "An ordered advisory value floor for an open setting. The server validates that its type matches every referring definition.",
"type": "object",
"additionalProperties": false,
"required": ["type", "options"],
"properties": {
"type": { "const": "language_tag" },
"options": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/suggestedOption" }
}
}
},
"scopeName": {
"description": "Storage identity a value attaches to. Whether a given scope is legal for a definition depends on its persistence class, which internal/settingscontract enforces.",
"type": "string",
"enum": [
"account",
"profile",
"profile_device",
"profile_library",
"profile_series",
"client_local"
]
},
"scopeEntry": {
"description": "A scope, optionally tagged with the revision that added it to this definition.",
"oneOf": [
{ "$ref": "#/$defs/scopeName" },
{
"type": "object",
"additionalProperties": false,
"required": ["scope"],
"properties": {
"scope": { "$ref": "#/$defs/scopeName" },
"introduced_in": { "$ref": "#/$defs/revisionRef" }
}
}
]
},
"integerBound": {
"description": "A numeric bound. Write a bare number for a bound that has never been widened. Widening replaces it with the full history, oldest first, so a client can recover the bound an older server still enforces; the bare form alone would discard it.",
"oneOf": [
{ "type": "integer" },
{
"type": "array",
"minItems": 2,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": { "type": "integer" },
"introduced_in": { "$ref": "#/$defs/revisionRef" }
}
}
}
]
},
"numberBound": {
"description": "A numeric bound. Write a bare number for a bound that has never been widened. Widening replaces it with the full history, oldest first, so a client can recover the bound an older server still enforces; the bare form alone would discard it.",
"oneOf": [
{ "type": "number" },
{
"type": "array",
"minItems": 2,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": { "type": "number" },
"introduced_in": { "$ref": "#/$defs/revisionRef" }
}
}
}
]
},
"enumMember": {
"description": "Enum members are objects so members added later can carry their own revision.",
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": { "type": ["string", "integer", "boolean"] },
"label": { "type": "string" },
"introduced_in": { "$ref": "#/$defs/revisionRef" },
"deprecated": { "type": "boolean", "default": false }
}
},
"valueSchema": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": { "const": "boolean" },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "minimum", "maximum"],
"properties": {
"type": { "const": "integer" },
"minimum": { "$ref": "#/$defs/integerBound" },
"maximum": { "$ref": "#/$defs/integerBound" },
"step": { "type": "integer", "exclusiveMinimum": 0 },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "minimum", "maximum"],
"properties": {
"type": { "const": "number" },
"minimum": { "$ref": "#/$defs/numberBound" },
"maximum": { "$ref": "#/$defs/numberBound" },
"step": { "type": "number", "exclusiveMinimum": 0 },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "max_length"],
"properties": {
"type": { "const": "string" },
"min_length": { "type": "integer", "minimum": 0, "default": 0 },
"max_length": { "type": "integer", "minimum": 1 },
"pattern": { "type": "string", "format": "regex" },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "values"],
"properties": {
"type": { "const": "enum" },
"values": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/enumMember" }
},
"ordered": {
"description": "Members form a meaningful progression. Required for ceiling/floor constraints.",
"type": "boolean",
"default": false
},
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": { "const": "language_tag" },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "schema_ref"],
"properties": {
"type": { "const": "object" },
"schema_ref": {
"description": "Filename under contracts/settings/v1/schemas/.",
"type": "string",
"pattern": "^[a-z0-9-]+\\.json$"
},
"nullable": { "type": "boolean", "default": false }
}
}
]
},
"constraint": {
"description": "Binding to a policy input that constrains this setting at resolution time.",
"type": "object",
"additionalProperties": false,
"required": ["policy_input", "constraint"],
"properties": {
"policy_input": {
"description": "Field name produced by internal/policy.",
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"constraint": {
"type": "string",
"enum": ["ceiling", "floor", "allowlist", "locked"]
}
}
},
"definition": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"introduced_in",
"persistence",
"allowed_scopes",
"resolution_order",
"value_schema",
"default_value",
"category",
"label",
"description"
],
"properties": {
"key": { "$ref": "#/$defs/settingKey" },
"introduced_in": { "$ref": "#/$defs/revisionRef" },
"persistence": {
"type": "string",
"enum": ["remote", "client_local"]
},
"allowed_scopes": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/scopeEntry" }
},
"resolution_order": {
"description": "Most specific first. Must end with \"default\".",
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"account",
"profile",
"profile_device",
"profile_library",
"profile_series",
"client_local",
"default"
]
}
},
"value_schema": { "$ref": "#/$defs/valueSchema" },
"default_value": {},
"constrained_by": { "$ref": "#/$defs/constraint" },
"platforms": {
"description": "Advisory UI metadata. Absent means \"expected everywhere\". Never server-enforced.",
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["web", "ios", "tvos", "macos", "android", "android_tv"]
}
},
"category": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"label": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"unit": { "type": "string" },
"recommended_control": {
"type": "string",
"enum": ["switch", "select", "slider", "stepper", "text", "color", "panel"]
},
"suggested_options": {
"description": "Advisory option-set reference for an open-value control. It does not turn the value schema into an enum.",
"$ref": "#/$defs/optionSetName"
},
"unset_label": {
"description": "Context-specific label for the nullable/unset row in a generated control.",
"type": "string",
"minLength": 1
},
"deprecated": { "type": "boolean", "default": false },
"notes": {
"description": "Maintainer commentary. Not served in the public manifest.",
"type": "string"
}
}
}
}
}
@@ -1,80 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/card-overlays.json",
"title": "Card overlay preferences",
"description": "Badges painted on poster cards. Mirrors CardOverlayPrefs in web/src/lib/overlays/types.ts.",
"type": "object",
"additionalProperties": false,
"required": ["version", "preset", "order", "items"],
"properties": {
"version": { "const": 2 },
"preset": {
"type": "string",
"enum": ["minimal", "classic", "vibrant", "pill", "square"]
},
"order": {
"description": "Explicit render order. Empty means use the registry's own order.",
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": { "$ref": "#/$defs/overlayId" }
},
"items": {
"type": "object",
"propertyNames": { "$ref": "#/$defs/overlayId" },
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "position"],
"properties": {
"enabled": { "type": "boolean" },
"position": {
"type": "string",
"enum": ["top-left", "top-right", "bottom-left", "bottom-right"]
},
"accentColor": {
"description": "Hex colour. Absent means the overlay's own default accent.",
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
},
"showIcon": {
"description": "Absent means inherit from the preset.",
"type": "boolean"
}
}
}
}
},
"$defs": {
"overlayId": {
"type": "string",
"enum": [
"resolution",
"hdr",
"resolution_hdr",
"audio",
"audio_channels",
"video_codec",
"container",
"aspect_ratio",
"release_type",
"edition",
"multi_audio",
"multi_sub",
"rating_imdb",
"rating_tmdb",
"rating_rt",
"rating_rt_audience",
"content_rating",
"year",
"runtime",
"original_language",
"studio",
"network",
"show_status",
"imdb_top_250",
"rt_certified_fresh"
]
}
}
}
@@ -1,13 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/library-id-list.json",
"title": "Library id list",
"description": "An ordered, duplicate-free list of library ids. Backs both ui.disabled_library_ids and ui.library_order; the web client normalizes with normalizeLibraryIDs in web/src/hooks/queries/libraries.ts, which drops non-integers and anything below 1.",
"type": "array",
"maxItems": 512,
"uniqueItems": true,
"items": {
"type": "integer",
"minimum": 1
}
}
@@ -1,32 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/library-page-state.json",
"title": "Library page state",
"description": "Remembered per-library browse state. Mirrors web/src/hooks/queries/libraryPageState.ts.",
"type": "object",
"additionalProperties": false,
"required": ["version", "libraries"],
"properties": {
"version": { "const": 1 },
"libraries": {
"type": "object",
"propertyNames": {
"type": "string",
"pattern": "^[0-9]+$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["search"],
"properties": {
"search": {
"type": "string",
"description": "Serialized URLSearchParams from serializeLibraryPageSearchParams. An advanced view encodes each filter rule as three groups[i][rules][j][...] keys, so the length grows about 150 characters per rule: measured at 216 for one rule, 518 for three, 820 for five. The bound has to clear what the current unvalidated endpoint already stores, or these rows fail the migration.",
"maxLength": 4096
}
}
},
"maxProperties": 512
}
}
}
@@ -1,20 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/metadata-language-overrides.json",
"title": "Metadata language exceptions",
"description": "Maps a media item's canonical original-language code to the metadata language a profile wants for that item. x-silo-original means use the source language itself.",
"type": "object",
"propertyNames": {
"description": "Canonical ISO 639 language code as stored in media_items.original_language.",
"type": "string",
"pattern": "^[a-z]{2,3}$"
},
"additionalProperties": {
"description": "A BCP 47 target language or the x-silo-original private-use sentinel.",
"type": "string",
"minLength": 2,
"maxLength": 255,
"pattern": "^([a-zA-Z]{2,3}(-[a-zA-Z]{3}){0,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-([0-9a-zA-Z]{5,8}|[0-9][0-9a-zA-Z]{3}))*(-[0-9a-wy-zA-WY-Z](-[0-9a-zA-Z]{2,8})+)*(-[xX](-[0-9a-zA-Z]{1,8})+)?|[xX](-[0-9a-zA-Z]{1,8})+)$"
},
"maxProperties": 256
}
@@ -1,27 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/sidebar-pins.json",
"title": "Sidebar pins",
"description": "Sections and collections pinned into the sidebar, grouped by the library they belong to. Mirrors SidebarPins in web/src/api/types.ts.",
"type": "object",
"propertyNames": {
"description": "The group the pins sit under — a library id, or a well-known group name.",
"type": "string",
"maxLength": 64
},
"maxProperties": 512,
"additionalProperties": {
"type": "array",
"maxItems": 128,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id", "label"],
"properties": {
"type": { "type": "string", "enum": ["section", "collection"] },
"id": { "type": "string", "minLength": 1, "maxLength": 128 },
"label": { "type": "string", "maxLength": 256 }
}
}
}
}
@@ -1,45 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/subtitle-appearance.json",
"title": "Subtitle appearance",
"description": "Rendering appearance for subtitle tracks. Shared by the web, Apple and Android players; where they disagree the wider vocabulary wins, because a value a shipped client can already produce must stay storable. A stored value is a sparse override: every property is optional, and a consumer merges what is present over this definition's default_value, which is complete. Requiring all nine would invalidate the partial objects the current API already stores and round-trips, so the migration would have to quarantine real user preferences. Resolution across scopes is unchanged and still first-wins — a device override replaces the profile's object rather than merging with it — because a device override means \"draw subtitles this way on this screen\", not \"amend the profile\".",
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"fontSize": {
"type": "string",
"enum": ["small", "medium", "large", "xlarge", "xxlarge"]
},
"fontFamily": {
"description": "A font family name. Not an enum: the Apple clients offer every family CTFontManagerCopyAvailableFontFamilyNames reports and store the chosen name verbatim, so restricting this to the web's three generic families would invalidate the stored appearance of every user who picked a real font. Family names are not ASCII — ヒラギノ角ゴ ProN is a stock macOS family — so the pattern excludes rather than allowlists: no control characters, quotes, separators, parentheses or braces, which keeps a value safe to interpolate into CSS or a platform font lookup while accepting any real family name.",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[^\\x00-\\x1f\"'(){};:,\\\\/ ][^\\x00-\\x1f\"'(){};:,\\\\/]*$"
},
"fontColor": { "$ref": "#/$defs/hexColor" },
"backgroundColor": { "$ref": "#/$defs/hexColor" },
"backgroundStyle": {
"type": "string",
"enum": ["box", "shadow", "outline", "none"]
},
"backgroundOpacity": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"textOutline": { "type": "boolean" },
"textOutlineColor": { "$ref": "#/$defs/hexColor" },
"position": {
"type": "string",
"enum": ["bottom", "lower-third", "top"]
}
},
"$defs": {
"hexColor": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
}
}
}
@@ -1,18 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/theme-var-overrides.json",
"title": "Theme variable overrides",
"description": "Sparse map of theme token to CSS value. Token names mirror web/src/lib/themeTokens.ts; values are bounded to keep this from becoming an untyped blob. The per-value bound is sized to what the web importer already accepts and stores: computed multi-stop gradients routinely pass 128 characters, so a tighter bound would invalidate themes users already imported.",
"type": "object",
"propertyNames": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"maxLength": 64
},
"additionalProperties": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"maxProperties": 256
}
-16
View File
@@ -18,22 +18,6 @@ When the scope locks, this file becomes the source of truth and will contain:
Until lock: treat any capability not tracked as `Proposed`/`Locked` on the project as out of scope
for feature PRs (see the scope gate in `CLAUDE.md`).
## Breaking removals taken before lock
The additive-only rule in item 2 binds at lock. Before then a removal is in scope, and there is no
amendment to write because the amendment process in item 3 does not exist yet. `CLAUDE.md` states
the rule without that qualifier, which reads as a contradiction — it is not, but a removal taken
now has to be recorded here so a reader after lock can tell a deliberate decision from a violation.
Each entry names what goes, why waiting is worse, and the design that decided it. **Every removal
listed here must have shipped before the scope locks.** One still outstanding at lock loses its
justification and falls back to the Deprecation/Sunset flow like anything else.
| Removed | Release | Rationale |
|---|---|---|
| String `GET`/`PUT`/`DELETE /api/v1/settings…`, the unknown-key extension bag, preference fields on profile/library/series DTOs | Cross-platform settings contract, [design](../superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md) | Replaced wholesale by the typed settings contract. Deferring past lock would mean carrying the Deprecation/Sunset surface *and* the untyped key bag — which lets any client invent a production setting the server stores unvalidated — through the deprecation window, which is the exact surface the contract exists to close. |
| The ten string-registry admin user-settings routes: `GET /api/v1/admin/users/{id}/settings`, `GET /api/v1/admin/users/{id}/settings/{key}`, `PUT /api/v1/admin/users/{id}/settings/{key}`, `DELETE /api/v1/admin/users/{id}/settings/{key}`, `GET /api/v1/admin/users/{id}/device-settings`, `GET /api/v1/admin/users/{id}/device-settings/{key}`, `DELETE /api/v1/admin/users/{id}/device-settings/{key}`, `PUT /api/v1/admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}`, `DELETE /api/v1/admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}`, `DELETE /api/v1/admin/users/{id}/profiles/{profile_id}/devices/{device_id}/settings` | Cross-platform settings contract, [design](../superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md) | The admin projection of the removal above: these routes read and wrote the string registry the contract replaces. Their canonical successors are `GET /api/v1/admin/users/{id}/settings/values` (every stored value across all scopes) and `PUT`/`DELETE /api/v1/admin/users/{id}/settings/values/{key}` at an explicit scope, sharing the session routes' validation. Keeping the string routes past lock would preserve an admin-only write path into the untyped bag after the user-facing one closed. |
Feature-detection precedent: clients discover which metadata providers (including the
built-in NFO provider, #216) apply to a library type via
`GET /api/v1/libraries/provider-defaults` rather than version sniffing. New capabilities
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
# Non-goals
Silo is an early WIP and most of its scope is open. This document is the short list of things
that are **not** open — features that will not be accepted, in core, in a plugin, or in a client,
regardless of implementation quality.
These are product-direction decisions, not "not yet" or "needs a better design." A well-written
proposal or a working PR does not change the answer. Please read this before opening an issue or
writing code in these areas, so nobody's time is wasted.
## Live TV, OTA tuners, IPTV, and remote stream shortcuts
**Out of scope permanently.** This covers, at minimum:
- Live TV of any kind, including OTA tuners (HDHomeRun and equivalents) and DVB devices.
- IPTV: M3U/M3U8 playlist ingestion, provider portals, Xtream-style APIs, stream relays.
- EPG/guide ingestion, XMLTV import, guide-provider sync, and channel lineup management.
- DVR: scheduled recordings, series rules, tuner arbitration.
- `.strm` files and any equivalent shortcut format whose contents are a remote media URL to be
fetched, proxied, redirected to, or transcoded by the server.
### Why
**App store distribution risk.** Silo's first-party clients ship on the Apple and Google stores.
Review of media apps in these categories is strict, and an app whose server plays arbitrary
remote stream URLs or IPTV playlists is read as a conduit for unlicensed content no matter what
the intended use is. The exposure is not scoped to the feature — a rejection or a takedown lands
on the entire client suite, for every user, including those who only ever play files they own.
No single feature is worth that trade.
**Product scope.** Silo indexes and plays media that exists on disk in a library the operator
controls. Live TV is a different product with its own always-on reliability burden: tuner
allocation, guide-provider contracts and merge semantics, schedule conflict resolution, and
recording lifecycle. Building it competes directly with making the core playback path fast and
correct, which is the stated priority.
**`.strm` is the same feature with a smaller diff.** A `.strm` file is a text file containing a
remote URL. Supporting it turns the server into a fetcher/transcoder/redirector for arbitrary
remote streams. The small change footprint is exactly what makes it worth naming explicitly here:
it is the capability without the label.
### Not available as a plugin either
The plugin runtime is not an escape hatch for this. Anything in this list needs first-party
client UI to be usable, which puts it back in the app store listings and reproduces the exact
problem above. Not in core, not in a plugin, not in a client.
### What to do instead
Silo does not assume it is the only media server on the box. Run Jellyfin, Plex, or a dedicated
tuner/IPTV backend alongside it for live TV, and use Silo for the on-disk library. That
separation also keeps the risk where it belongs, on software whose distribution model can absorb
it.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

@@ -1,306 +0,0 @@
# Implementation Plan: Emailed Invitations &amp; Server-Driven Onboarding
**Spec:** `docs/superpowers/specs/2026-07-27-invitations-and-onboarding-design.md`
**Mockups:** `docs/design/invite-onboarding.html`
**Date:** 2026-07-27
Commands assume the repository root is the cwd.
## Shape of the work
Two features that share a journey but not a dependency. **Part 1
(invitations) and Part 2 (onboarding) are independently shippable** — build
and merge them in that order, but neither blocks the other if priorities
shift.
Phases 1–4 are silo-server and ship as one PR each. Phases 5–6 are the client
repos and follow after the server contract is merged and stable.
Resolve the spec's three open questions before starting Phase 1 — expiry
configurability, replay entry point, and `tour_id` re-prompt semantics all
affect the schema.
---
# Part 1 — Invitations (silo-server)
## Phase 1 — Migration
`make migrate-create NAME=invitations` — one timestamped Goose migration, no
hand-numbering, no paired up/down files.
Up: the `invitations` table exactly as specced, plus the two indexes. The
partial unique index is the load-bearing one:
```sql
CREATE UNIQUE INDEX invitations_one_pending_idx ON public.invitations (email)
WHERE accepted_at IS NULL AND revoked_at IS NULL;
```
It is what makes "resend supersedes" true at the database level rather than
only in application code.
Down: `DROP TABLE public.invitations;`.
Verify with `make migrate-up && make migrate-status`.
## Phase 2 — Backend: model, repo, mail, service
New package `internal/invitations`. It owns the whole feature; nothing goes in
a catch-all helper.
**`internal/models/invitation.go`** — `Invitation`, `CreateInvitationInput`,
and a derived `Status()` method returning `pending|accepted|expired|revoked`
computed from the timestamps. No status column.
**`internal/invitations/repository.go`** — CRUD plus:
- `Create` — mints the token, stores only the SHA-256 hex. Returns the raw
token to the caller once and never again.
- `GetByTokenHash` — the claim lookup.
- `Accept(ctx, tokenHash, userID)` — `UPDATE ... WHERE token_hash = $1 AND
accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now()`. Zero
rows affected means already-used/expired/revoked; the caller distinguishes
by re-reading. This is the concurrency guard for double-submit.
- `Revoke`, `List`.
Mirror the token helpers in `internal/notifications/email_address.go`
(`newEmailToken` / `hashEmailToken`) rather than reinventing them — consider
lifting them to a shared spot if the duplication grates, but do not
restructure `notifications` as a side effect of this work.
**`internal/invitations/email.go`** — `composeInvitationEmail`, built from
`mail.RenderLayout` / `EmailParagraph` / `EmailButton`
(`internal/mail/layout.go:88`). Escape the admin note. Link base resolution
reuses the `notifications.email.external_url` → public URL precedence.
**`internal/invitations/service.go`** — orchestration:
- `Send` — validates the address, supersedes any pending invite for it,
creates, sends. On `mail.ErrNotConfigured`, returns the row plus the claim
URL and `emailSent=false` rather than erroring. Rejects a `role: admin`
invitation from a non-admin inviter (enforce at the service, not the UI).
- `Lookup` — claim-screen projection only: inviter display name, email,
expiry. Nothing else leaves the server.
- `Accept` — one transaction: `auth.AccountProvisioner.CreateAccount`
(`internal/auth/account_provisioner.go`, which already rolls back the user if
profile creation fails) with the pre-bound role/group/libraries, then
`repository.Accept`, then log in via the existing session path so the
response is a normal `TokenPair`.
**Login fallback** — `internal/auth/provider.go:56`. `LocalProvider.Authenticate`
currently calls `GetByUsername` only. Add: on not-found, if the input parses as
an email address (`net/mail.ParseAddress`), retry `GetByEmail`. Keep the
password comparison path identical so timing does not diverge between the two
lookups.
Tests:
- Accept twice concurrently → exactly one account, one 4xx.
- Accept an expired invitation → refused, no user row.
- Resend invalidates the prior token.
- Email login fallback resolves; username login is unchanged.
- Non-admin inviter cannot mint an admin invitation.
## Phase 3 — Backend: HTTP
**`internal/api/handlers/admin_invitations.go`** — list/create/resend/revoke,
following `admin_invite_codes.go` for shape and error mapping.
**`internal/api/handlers/invitations.go`** — the two public endpoints.
`GET /invitations/{token}` returns an identical 404 for unknown, expired,
revoked, and accepted; do not leak which.
**`internal/api/router.go`**:
- Admin routes beside `/invite-codes` (~line 2891), same admin gate.
- Public routes beside `/auth`, wrapped in
`deps.RateLimitMW.AuthEndpointHandler("invitation")` where the limiter is
present, matching the `login`/`signup` treatment at lines 1708–1716.
`accept` returns `buildLoginResponse` — the same shape as signup — so no
client grows a parallel auth path.
## Phase 4 — Web: admin tab + claim page
**`web/src/pages/admin-settings/InvitationsTab.tsx`** — table plus composer
dialog, per the mockup. Follow `InviteCodesTab.tsx` for structure and the
`useAdminInviteCodes` hooks for the query layer. Register the tab in
`AdminUsers.tsx` beside the existing `invite-codes` tab.
When the create response carries `email_sent: false`, swap the success toast
for a copyable link and say plainly that email is not configured.
**`web/src/pages/InviteClaim.tsx`** — public route `/invite/:token`, added to
`App.tsx` beside `/signup` (line 373). Reuse `auth-shell`, `AuthBackground`,
and the `PasswordInput` component. Expired/used/revoked renders an explanatory
card with a sign-in link, never a bare 404.
On success, store the returned tokens through the existing auth hook and route
onward — do not bounce the user back to `/login`.
**`web/src/pages/HouseholdSetup.tsx`** — the "Who's watching?" step, routed to
directly after a successful accept. **No new backend**: each tile posts through
the existing `POST /profiles` mutation, whose request already carries name,
avatar, PIN, `is_child`, `max_content_rating`, and per-profile library
restrictions (`createProfileRequest`, `internal/api/handlers/profiles.go:50`).
- The first tile is the invitee's own profile, pre-named from the email local
part, renameable inline.
- The add-profile dialog per the mockup: kids toggle reveals rating ceiling
(default PG) + library picker; PIN toggle carries the "pin the adults, not
the kids" copy.
- "Just me for now" exits without creating anything.
- Reuse the avatar preset picker and PIN dialog pieces from
`ProfilesSettings.tsx` rather than duplicating them — extract shared
components if needed.
Add `Invitation` types to `web/src/api/types.ts` and hooks under
`web/src/hooks/queries/admin/invitations.ts`.
---
# Part 2 — Onboarding (silo-server, then clients)
## Phase 5 — Backend: manifest + state
**userdb schema** — add `profile_onboarding` keyed `(profile_id, tour_id)`
with `last_step`, `completed_at`, `skipped_at`. This needs a
`schemaVersion` bump in `internal/userdb/migrate.go` (currently 13) plus the
matching migration step; follow how the existing per-profile tables
(`subtitle_preferences`, `series_playback_preferences`) were added. Do **not**
use `user_settings` — it is keyed by `key` alone and therefore account-wide,
so one profile finishing would silence the tour for the whole household.
**`internal/onboarding`** — new package:
- `steps.go` — the manifest as Go data. Copy lives here.
- `filter.go` — per-server and per-surface filtering. Query the real feature
gates: `requests_enabled` via `internal/requests`, watch-together, whether
`mail.Sender.Enabled()` or push is configured, recommendations. Drop
`spotlight` and any text-entry step for `surface=tv`.
- `state.go` — read/write via the userdb store.
**Handlers + routes** — `GET /onboarding/flow`, `GET /onboarding/state`,
`POST /onboarding/progress`, all profile-scoped (`apimw.RequireProfile`, as the
`/settings/effective` group does at router.go:2242).
The manifest must only emit a `setting_choice` the current profile may write —
check child-profile restrictions before including a step, rather than letting
the client discover the refusal.
Tests: a server with requests disabled omits the requests step; `surface=tv`
omits spotlights; completing on one profile does not mark another complete.
## Phase 6 — Web: the tour
**`web/src/components/onboarding/`** — `TourHost` (the state machine plus
progress POSTs) and one renderer per step kind. Unknown `kind` → skip, no
error. This is the compatibility guarantee; test it explicitly with a
fabricated future kind.
`setting_choice` dispatches on `target`:
- `profile_field` → `PUT /profiles/{id}` via the existing update mutation
(see `saveProfileField`, `web/src/pages/settings/PlaybackSettings.tsx:114`)
- `setting` → the existing `useSetSetting`
- `device_setting` → the existing device-setting mutation
Reusing those mutations means the tour writes the same rows the settings
screens write and inherits their cache invalidation for free.
**Entry** — extend the existing `TasteSeedGate` in `App.tsx` (line 234) into an
onboarding gate that checks server state first, runs the tour, then hands off
to `/taste-seed` via the `handoff` step. The tour precedes taste-seed rather
than replacing it.
**Replay** — an entry point in Settings → Personalize (pending the spec's open
question 2).
## Phase 7 — Android (silo-android)
Separate repo, separate PR, after the server contract is merged.
- Models + client method for the three onboarding endpoints in
`shared/src/commonMain/kotlin/.../model`, alongside the existing auth models.
- Compose tour UI in `androidApp/.../ui/screens/onboarding/`: a full-screen
`HorizontalPager`, one step per page, skip always reachable. Unknown kinds
filtered out at parse time.
- Invite deep link: add an `invite` host to the existing `silo` scheme
`intent-filter` in `AndroidManifest.xml` (lines 49–60) plus an `https` App
Link, and a claim screen beside `SignupScreen.kt`. Parse via the existing
`ContentDeepLinkRoutes` machinery.
- Household setup screen after claim: profile tiles + add-profile sheet,
posting through the existing profiles endpoint. The kid preset mirrors the
web dialog (rating ceiling + libraries).
- TV (`androidTvApp`) requests `surface=tv` and renders the focus-driven
variant — no keyboard entry.
## Phase 8 — Apple (silo-apple)
Separate repo, separate PR. **Build and validate on `mac-builder` through the
`xcodebuildmcp` MCP server**, preserving the exact commit plus dirty and
untracked state — do not validate against a stale remote checkout.
- Networking models beside `DeviceLoginModels.swift`.
- `Screens/Onboarding/` — SwiftUI `TabView(.page)` tour reusing the Aurora
design system (`AuroraScreen`, `AuroraPrimaryButtonStyle`). Unknown kinds
filtered at decode.
- Claim screen in `Screens/Auth/` beside `SignupView.swift`, reusing
`AuroraJourneyProgress` (`DesignSystem/Aurora/AuroraStyle.swift:61`) with the
invite path as step 2. Note `AuroraJourneyProgress` hardcodes
`["Server", "Account", "Profile"]` — it needs a labels parameter for the
invite journey (`Server → Password → Household`).
- Household setup after claim, adjacent to the existing
`Screens/Profiles/ProfileSelectionView.swift`: tiles + add-profile sheet
posting through the existing profiles endpoint, kid preset matching web.
- Deep link: add an `invite` case to the `onOpenURL` handler in `iOSApp.swift`
(line 51), which already queues links until auth settles. Universal links
additionally need the server to serve
`/.well-known/apple-app-site-association` — a small server-side follow-up,
not a blocker for the custom scheme.
- tvOS requests `surface=tv`.
---
## Verification
Per server PR:
```bash
make lint
cd web && pnpm run lint && pnpm run format:check
make verify-local-paths
```
Plus `make migrate-up` / `make migrate-status` for Phases 1 and 5, and browser
verification of the web flows via the `web-ui-testing` skill with screenshots
for the PR.
## Commit sequence
One PR per phase, Conventional Commit subjects, one concern each:
| Phase | Repo | Subject |
| --- | --- | --- |
| 1–3 | silo-server | `feat(invitations): add emailed pre-provisioned invitations` |
| 4 | silo-server | `feat(web): add invitation admin tab and claim page` |
| 5 | silo-server | `feat(onboarding): add server-driven onboarding manifest` |
| 6 | silo-server | `feat(web): add first-run feature tour` |
| 7 | silo-android | `feat(onboarding): add invite claim and feature tour` |
| 8 | silo-apple | `feat(onboarding): add invite claim and feature tour` |
Phases 1–3 are one PR: the migration, repo, service, and handlers are a single
coherent unit and splitting them leaves a table nothing reads.
Each PR needs `Part of #NNN` against the capability epic, the AI-use disclosure
block per `docs/ai-contributions.md`, and screenshots for the UI phases.
## Risks
**Login lookup change (Phase 2)** is the highest-risk item — it touches the
path every existing user authenticates through. It is additive (fallback only
on miss), but it warrants explicit regression tests for username login and a
careful review.
**userdb schema bump (Phase 5)** changes per-user SQLite stores. Follow the
existing migration test pattern (`internal/userdb/migrate_v13_test.go`) and
verify an older store upgrades cleanly.
**Client contract drift** is bounded by "unknown kinds are skipped" — but that
only holds if each client actually implements the skip. Test it on all three
before the first post-launch manifest change, not after.
@@ -1,206 +0,0 @@
# Transcode Resolution Clamp Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Prevent encoded-video transports from targeting a resolution above the final effective source file.
**Architecture:** Add one pure resolution-normalization helper in the playback API handler and invoke it exactly once after alternate-file selection. Mutate the request's target resolution at that boundary so remote transport, local transport, persistence, activity reporting, and reconstruction consume the same normalized recipe.
**Tech Stack:** Go, `net/http` handler tests, Silo playback session and transcode-node test doubles.
## Global Constraints
- Do not apply the new clamp to video-copy requests; preserve their existing
downstream recipe normalization.
- Preserve empty or unrecognized requested/source resolutions unchanged.
- Clamp only recognized encoded-video targets above the recognized effective source.
- Do not alter bitrate, codecs, API shapes, status codes, settings, migrations, clients, or production configuration.
- Prove the regression RED before writing production code.
---
### Task 1: Normalize the Effective Transcode Recipe
**Files:**
- Modify: `internal/api/handlers/playback.go`
- Test: `internal/api/handlers/playback_test.go`
**Interfaces:**
- Consumes: `transcodeStartRequest.TargetResolution`, `transcodeStartRequest.TargetCodecVideo`, and `models.MediaFile.Resolution` after alternate-file selection.
- Produces: `clampEncodedTargetResolution(requestedResolution, sourceResolution string) string`.
- Produces: one normalized `req.TargetResolution` consumed unchanged by remote/local transport construction and `buildTranscodeSessionReplacement`.
- [ ] **Step 1: Strengthen the existing remote fallback test**
Change `TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback` to
request 2160p at 10000 kbps. Assert that the captured
`transcodenode.TranscodeStartRequest.TargetResolution` is `1080p`, while the
effective alternate remains file 99 and the resulting playback session stores
`TargetResolution == "1080p"`.
```go
strings.NewReader(`{"session_id":"` + startResp.SessionID +
`","seek_seconds":0,"target_resolution":"2160p",` +
`"target_codec_video":"h264","target_codec_audio":"aac",` +
`"target_bitrate_kbps":10000,"segment_duration":2,` +
`"subtitle_track_index":-1,"subtitle_burn_in":false}`)
if remoteStartReq.TargetResolution != "1080p" {
t.Fatalf("remote target resolution = %q, want 1080p", remoteStartReq.TargetResolution)
}
if session.TargetResolution != "1080p" {
t.Fatalf("session target resolution = %q, want 1080p", session.TargetResolution)
}
```
- [ ] **Step 2: Run the regression and verify RED**
Run:
```bash
go test ./internal/api/handlers -run TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback -count=1
```
Expected: FAIL because the captured remote request still contains `2160p`.
- [ ] **Step 3: Add pure normalization table tests**
Add `TestClampEncodedTargetResolution` beside the handler tests. Cover:
```go
tests := []struct {
name, requested, source, want string
}{
{"clamps 2160p to 1080p", "2160p", "1080p", "1080p"},
{"keeps lower target", "720p", "1080p", "720p"},
{"keeps equal target", "1080p", "1080p", "1080p"},
{"keeps empty target", "", "1080p", ""},
{"keeps unknown target", "source", "1080p", "source"},
{"keeps target for unknown source", "2160p", "native", "2160p"},
{"supports low tiers", "480p", "420p", "420p"},
}
```
- [ ] **Step 4: Add the minimal helper and post-selection call**
Add a package-level recognized-height map/helper near `resolutionRank`:
```go
func clampEncodedTargetResolution(requestedResolution, sourceResolution string) string {
requestedHeight, requestedKnown := transcodeResolutionHeight(requestedResolution)
sourceHeight, sourceKnown := transcodeResolutionHeight(sourceResolution)
if !requestedKnown || !sourceKnown || requestedHeight <= sourceHeight {
return requestedResolution
}
return sourceResolution
}
```
After the 4K alternate block and before transport planning:
```go
if !videoCopy {
req.TargetResolution = clampEncodedTargetResolution(
req.TargetResolution,
file.Resolution,
)
}
```
Use an exhaustive switch for the six supported transcode tiers. Do not modify
the FFmpeg filter builder or web client.
- [ ] **Step 5: Verify focused GREEN**
Run:
```bash
go test ./internal/api/handlers \
-run 'TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback|TestClampEncodedTargetResolution' \
-count=1
```
Expected: PASS.
- [ ] **Step 6: Add copy and local-boundary regression coverage**
Add focused tests proving:
- a copy-video request bypasses the new clamp and retains its existing
downstream empty-resolution recipe;
- a local encoded-video request receives the clamped target in the persisted
session and generated FFmpeg recipe boundary;
- a requested 720p encode for a 1080p effective file remains 720p.
Use the existing local fake-FFmpeg and session-manager patterns in
`playback_test.go`; do not introduce production test hooks.
- [ ] **Step 7: Run focused and package verification**
Run:
```bash
gofmt -w internal/api/handlers/playback.go internal/api/handlers/playback_test.go
go test ./internal/api/handlers -count=1
go test ./internal/playback -count=1
go test ./internal/api/handlers ./internal/playback -race -count=1
git diff --check
```
Expected: all commands exit 0.
- [ ] **Step 8: Commit implementation**
```bash
git add internal/api/handlers/playback.go internal/api/handlers/playback_test.go
git commit -m "fix(playback): prevent transcode resolution upscaling"
```
### Task 2: Independent Review and Final Verification
**Files:**
- Review: `docs/superpowers/specs/2026-07-28-transcode-resolution-clamp-design.md`
- Review: `internal/api/handlers/playback.go`
- Review: `internal/api/handlers/playback_test.go`
**Interfaces:**
- Consumes: committed spec and implementation diff from `upstream/main`.
- Produces: reviewer verdict and a clean, verified branch suitable for a separate pull request.
- [ ] **Step 1: Request focused correctness/security review**
Review the change against these invariants:
- no encoded output exceeds the effective source tier;
- copy and unknown-resolution compatibility is preserved;
- local, offloaded, persisted, and reconstruction paths share one value;
- no target-bitrate or API behavior was broadened;
- tests fail without the production correction.
- [ ] **Step 2: Address Critical or Important findings test-first**
For each accepted behavioral finding, add or strengthen a failing test, observe
RED, make the smallest production correction, and rerun focused GREEN. Do not
bundle unrelated cleanup.
- [ ] **Step 3: Run final verification**
Run:
```bash
go test ./internal/api/handlers ./internal/playback -count=1
go test ./internal/api/handlers ./internal/playback -race -count=1
go test ./... -count=1
make verify-local-paths
git diff --check
git status --short --branch
```
Expected: tests and repository policy checks exit 0; status contains no
uncommitted implementation changes.
- [ ] **Step 4: Prepare handoff**
Summarize the production evidence, exact normalization boundary, RED/GREEN
proof, focused/full verification, reviewer verdict, and deployment caveat.
Do not deploy or mutate production as part of this plan.
@@ -1,510 +0,0 @@
# User-Facing Device Settings Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> Commands assume the repository root is the cwd.
**Goal:** Let a person see and change the device settings for every device they watch on, from whichever device they are holding — and let the household parent do the same for everyone on the account.
**Architecture:** No new storage and 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 the work is authorization plus routes plus UI. Two identity widenings on the existing canonical settings API, each behind a guard: a caller may name a `device_id` other than the request's own (checked against `user_devices`), and a household parent may name a `profile_id` other than their own (checked by the existing `canManageHouseholdProfiles`). One new self-service device registry endpoint, deliberately profile-filtered by default. One new settings page reusing `SettingsGroup`/`SettingRow`.
**Tech Stack:** Go, `net/http` handler tests, `internal/userstore/storetest` conformance suite, React 19 + react-router v7 + TanStack Query, Vitest + Testing Library, shadcn/ui primitives.
**Design source:** `docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md`. Mockups were reviewed out-of-band; the shipping shape is the "B2 + B3" pair — a searchable device list with an editable detail pane, plus a household scope switch for the primary profile.
## Global Constraints
- **Additive only.** No existing response field is renamed, retyped, or removed, and no status code is repurposed. New behavior arrives as new endpoints or new optional query parameters. See `CLAUDE.md` "v1 API rules".
- **The header stays the default.** When `device_id` / `profile_id` are absent from the query, every existing route must behave exactly as it does today. Existing clients must not need a change.
- **No new hand-written setting metadata.** Labels, descriptions, controls, options and bounds come from `contracts/settings/v1/manifest.json` via the generated `web/src/lib/settingsContract.ts`. A per-key table beside the generated one is exactly the drift the contract exists to remove.
- **Never render raw setting keys** in user-facing UI.
- **Scope wording is mandated** by the design spec: "this device, for your profile only". Do not invent alternatives such as "global", "default", or a bare "this device".
- **Restrictions are not preferences.** Policy caps are explained with the permitted value and the reason; they are never rendered as a disabled control with no explanation, and this screen never authors a restriction.
- Prove each authorization regression RED before writing production code.
- Do not edit this plan file while implementing it.
---
## Phase 1 — Server: identity widening and the device registry
### Task 1: Reject a device the caller does not own
`completeIdentity` validates an identity's *shape* but never that a `profile_device` identity names a device belonging to the caller. That is safe today only because `DeviceID` is taken from the request's own header. Task 2 removes that guarantee, so the check lands first.
**Files:**
- Modify: `internal/api/handlers/settings_values.go`
- Modify: `internal/userstore/store.go`
- Modify: `internal/userstore/pgstore/settings.go`
- Modify: `internal/userdb/settings.go` (the per-user SQLite backend)
- Test: `internal/api/handlers/settings_values_test.go`
- Test: `internal/userstore/storetest/settingvalues.go`
**Interfaces:**
- Produces: `DeviceRegistry.DeviceExists(ctx, profileID, deviceID string) (bool, error)` — a targeted existence check rather than a full `ListDevices` scan on every write.
- Produces: a device-ownership guard invoked from `completeIdentity` for `ScopeProfileDevice`.
- [ ] **Step 1: Write the failing ownership test**
Add `TestSetValue_RejectsDeviceNotOwnedByCaller` to `settings_values_test.go`: register device `dev-a` for the caller, then `PUT /settings/values/player.hdr_enabled?scope=profile_device&device_id=dev-someone-else`. Expect `404` with error code `not_found` — not `403`, which would confirm the device id exists.
Because Task 2 has not landed, the query parameter is ignored and the write silently succeeds against the header device. Assert on the *stored* row, so the test fails for the right reason:
```go
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" {
t.Fatalf("wrote a row for device %q; want no write", got)
}
```
- [ ] **Step 2: Run the test and verify RED**
```bash
go test ./internal/api/handlers/ -run TestSetValue_RejectsDeviceNotOwnedByCaller -v
```
- [ ] **Step 3: Add `DeviceExists` to the store interface and both backends**
Postgres: `SELECT EXISTS(SELECT 1 FROM user_devices WHERE user_id = $1 AND profile_id = $2 AND device_id = $3)`. SQLite: the same without `user_id`, matching the existing `ListDevices` asymmetry (one DB per user). Add the case to the shared conformance suite in `internal/userstore/storetest/settingvalues.go` so both backends are held to it.
- [ ] **Step 4: Enforce it in `completeIdentity`**
For `ScopeProfileDevice`, when the device id did **not** come from the request header, verify it exists for `(profileID, deviceID)`. A device that is unknown returns 404 `not_found`. Registering-on-write stays the behavior for the caller's own header device, so a brand-new device can still store its first value.
- [ ] **Step 5: Run and verify GREEN**
```bash
go test ./internal/api/handlers/ ./internal/userstore/... -run 'Device|SettingValue' -v
```
---
### Task 2: Accept an explicit `device_id` on the self-service settings routes
**Files:**
- Modify: `internal/api/handlers/settings_values.go`
- Test: `internal/api/handlers/settings_values_test.go`
**Interfaces:**
- Consumes: optional `device_id` query parameter on `GET|PUT|DELETE /settings/values/{key}` and `GET /settings/values` when `scope=profile_device`.
- Produces: `identityForSessionKey` resolving `DeviceID` from the query when present, else from `X-Silo-Device-Id` exactly as today.
- [ ] **Step 1: Write the failing tests**
Three cases in `settings_values_test.go`:
- `TestSetValue_WritesNamedDevice` — register `dev-b`, `PUT …?scope=profile_device&device_id=dev-b` from a request whose header is `dev-a`; assert the stored row is on `dev-b` and `dev-a` has none.
- `TestGetValues_ReadsNamedDevice` — same shape for the read path.
- `TestSetValue_FallsBackToHeaderDevice` — no `device_id` in the query writes the header device. This is the regression guard for every existing client.
- [ ] **Step 2: Run and verify RED**
```bash
go test ./internal/api/handlers/ -run 'NamedDevice|FallsBackToHeaderDevice' -v
```
- [ ] **Step 3: Implement**
In `identityForSessionKey`, prefer a non-empty `device_id` from the query and fall through to `deviceMetadataFromRequest(r).DeviceID`. Update the comment at the profile assignment so it still describes reality: the profile remains session-derived here; only the device may be named. Keep the existing 400 when neither source yields a device id.
Do **not** call `registerWritingDevice` for a named device — registration is a statement that *this* device is in use, and a remote write is not that.
- [ ] **Step 4: Run and verify GREEN, then run the whole handler package**
```bash
go test ./internal/api/handlers/ -v
```
---
### Task 3: `GET /api/v1/devices` — list your own devices
**Files:**
- Create: `internal/api/handlers/devices.go`
- Create: `internal/api/handlers/devices_test.go`
- Modify: `internal/api/router.go`
**Interfaces:**
- Produces: `GET /api/v1/devices` → `{"devices":[{device_id, device_name, device_platform, last_seen_at, profile_id, profile_name, is_current_device, changed_count}]}`.
- Consumes: `DeviceRegistry.ListDevices`, `ListAllSettingValues`, `X-Silo-Device-Id`.
**The trap this task exists to avoid:** `ListDevices` is account-wide in *both* backends — `WHERE user_id = $1` in `internal/userstore/pgstore/settings.go:115`, and no `WHERE` clause at all in `internal/userdb/settings.go:112` because there is one DB per user. `profile_id` is a selected column, never a predicate. A naive passthrough would show every household member's devices to everyone. The handler must filter.
- [ ] **Step 1: Write the failing tests**
- `TestListDevices_FiltersToCallingProfile` — seed devices for profile A and profile B, call as A, assert only A's are returned. **This is the security test; write it first.**
- `TestListDevices_CountsChangedSettings` — `changed_count` equals the number of `profile_device` rows for that `(profile, device)`.
- `TestListDevices_MarksCurrentDevice` — the device matching `X-Silo-Device-Id` has `is_current_device: true`.
- [ ] **Step 2: Run and verify RED**
```bash
go test ./internal/api/handlers/ -run TestListDevices -v
```
- [ ] **Step 3: Implement the handler**
Filter `ListDevices` output to `apimw.GetProfileID(ctx)`. Derive `changed_count` from `ListAllSettingValues` filtered to `scope == profile_device` and the same profile — one store round trip, not one per device. `profile_name` comes from the existing `listProfileNamesByID` helper pattern in `internal/api/handlers/admin.go`.
- [ ] **Step 4: Register the route**
In `internal/api/router.go`, inside the authenticated group, `r.With(apimw.RequireProfile).Get("/devices", devicesHandler.HandleListDevices)`. Place it away from the `/devices/push/apple` line so the two device namespaces stay visibly distinct.
- [ ] **Step 5: Run and verify GREEN**
```bash
go test ./internal/api/handlers/ -run TestListDevices -v
```
---
### Task 4: Forget a device, and bulk-clear one device
**Files:**
- Modify: `internal/api/handlers/devices.go`
- Modify: `internal/api/router.go`
- Modify: `internal/userstore/store.go` (if a targeted delete is missing)
- Test: `internal/api/handlers/devices_test.go`
**Interfaces:**
- Produces: `DELETE /api/v1/devices/{device_id}` — forget: clears settings **and** the registry row.
- Produces: `DELETE /api/v1/devices/{device_id}/settings` — clear overrides, keep the device.
The design spec requires Forget device (`…-design.md:416`) and lists it as outstanding (`:1202`). `DeleteAllDeviceSettings` exists and clears both storage generations but is reachable only through profile deletion today. Bulk clear also fixes the admin screen's 30-sequential-DELETE loop in `web/src/hooks/queries/admin/users.ts:451`.
- [ ] **Step 1: Write failing tests**
- `TestForgetDevice_RemovesSettingsAndRegistryRow`
- `TestForgetDevice_RejectsOtherProfilesDevice` → 404
- `TestClearDeviceSettings_KeepsRegistryRow`
- `TestForgetDevice_IsIdempotent` — a second call returns 204, not 500
- [ ] **Step 2: Run and verify RED**
```bash
go test ./internal/api/handlers/ -run 'ForgetDevice|ClearDeviceSettings' -v
```
- [ ] **Step 3: Implement both routes**
Reuse `DeleteSettingValuesForDevice` (`internal/userstore/store.go:216`) and `DeleteAllDeviceSettings`. Publish `user_settings.changed` once per cleared key so other devices invalidate — or once for the device if a batch event shape is added; do not skip the event.
- [ ] **Step 4: Run and verify GREEN**
```bash
go test ./internal/api/handlers/ ./internal/userstore/... -v
```
---
## Phase 2 — Server: the household tier
### Task 5: Extract the household-parent guard
`canManageHouseholdProfiles` (`internal/api/handlers/profiles.go:146`) already encodes exactly the right rule — server admin, or an `is_primary` active profile, and when that profile has a PIN a verified `X-Profile-Token` so sending only `X-Profile-Id` cannot bypass the profile lock. It is a method on `ProfileHandler`, so `SettingValuesHandler` cannot call it.
**Files:**
- Create: `internal/api/handlers/household.go`
- Modify: `internal/api/handlers/profiles.go`
- Modify: `internal/api/handlers/settings_values.go`
- Modify: `internal/api/router.go`
- Test: `internal/api/handlers/household_test.go`
**Interfaces:**
- Produces: `canManageHousehold(r *http.Request, store userstore.UserStore, tokens ProfileTokenValidator) (bool, error)` — a package-level function.
- `ProfileHandler.canManageHouseholdProfiles` becomes a thin wrapper so its four existing call sites and their behavior are untouched.
- `SettingValuesHandler` gains a `ProfileTokens` field, wired in `internal/api/router.go` next to `profileHandler.ProfileTokens = profileTokenService` (`router.go:812`).
- [ ] **Step 1: Characterization tests before moving anything**
In `household_test.go`, cover: admin → true; primary without PIN → true; primary with PIN and no token → `access.ErrProfileUnverified`; primary with PIN and valid token → true; non-primary → false; no active profile → false. Run them against the *existing* method first so the extraction is provably behavior-preserving.
- [ ] **Step 2: Extract, then re-run**
Move the body to the package-level function; leave the method delegating. Run the full profiles suite — those four call sites are the regression surface:
```bash
go test ./internal/api/handlers/ -run 'Profile|Household' -v
```
- [ ] **Step 3: Wire `ProfileTokens` into `SettingValuesHandler`**
Nil `ProfileTokens` must mean "no household widening", never "allow" — assert that in a test.
---
### Task 6: Accept an explicit `profile_id` for the household parent
**Files:**
- Modify: `internal/api/handlers/settings_values.go`
- Modify: `internal/api/handlers/devices.go`
- Test: `internal/api/handlers/settings_values_test.go`
- Test: `internal/api/handlers/devices_test.go`
**Interfaces:**
- Consumes: optional `profile_id` query parameter on the settings-value routes and on `GET /api/v1/devices`.
- Produces: identity resolution that permits a non-own `profile_id` only when `canManageHousehold` passes.
- [ ] **Step 1: Write the three refusal tests first**
These are the security surface. All three must be RED before any production code:
- `TestSetValue_NonPrimaryCannotNameSiblingProfile` → 403
- `TestSetValue_PrimaryWithUnverifiedPINCannotNameSibling` → 403, code `forbidden`, message naming PIN verification
- `TestSetValue_ProfileFromAnotherAccountIsNotFound` → 404
Then the positive cases: `TestSetValue_PrimaryWritesSiblingProfileDeviceSetting`, `TestListDevices_PrimarySeesHouseholdWhenRequested`.
- [ ] **Step 2: Run and verify RED**
```bash
go test ./internal/api/handlers/ -run 'NameSibling|AnotherAccount|PrimaryWrites|PrimarySees' -v
```
- [ ] **Step 3: Implement**
In `identityForSessionKey`: when `profile_id` is present and differs from the session profile, require `canManageHousehold`; on failure return 403 without disclosing whether the profile exists. Then resolve the profile through the caller's **own** store, exactly as `internal/access/resolver.go:73-86` does — a profile from another account is simply absent, which yields the 404 and preserves the cross-account boundary for free.
For `GET /api/v1/devices`, add `?scope=household` (default: own profile only). Do not make household the default; the plain screen must stay private by construction.
- [ ] **Step 4: Run and verify GREEN, then the full package**
```bash
go test ./internal/api/handlers/ -v
```
**Record in the PR:** this widening does not create a new capability. The primary profile can already rewrite a sibling's canonical settings rows through `PUT /profiles/{id}` (`internal/api/handlers/profiles_settings_sync.go:218-231`), and `GET /profiles` (`profiles.go:263`) already returns every sibling's resolved preferences to any profile with no gate. All profiles share one login session, so `X-Profile-Id` is self-asserted for PIN-less profiles — stated in-repo at `internal/api/middleware/auth.go:180-184`. This task replaces an unlabelled path with a guarded, audited one.
---
### Task 7: Audit cross-profile and device settings mutations
`internal/activitylog` is only an HTTP request-log middleware mounted globally before auth (`internal/api/router.go:236-239`); no handler writes to it. Its entries carry method, path pattern, status, user and session but **no profile id and no body**, so a settings write is indistinguishable from any other `PUT`. Tolerable while every write is your own; not once one profile can change another's. The settings spec already requires admin clear/reset to be audited.
**Files:**
- Modify: `internal/activitylog/` (new entry type or a settings-audit sink)
- Modify: `internal/api/handlers/settings_values.go`
- Modify: `internal/api/handlers/settings_values_admin.go`
- Modify: `internal/api/handlers/devices.go`
- Test: `internal/api/handlers/settings_values_test.go`
- [ ] **Step 1: Write failing tests**
`TestSetValue_AuditsCrossProfileWrite` asserts an entry recording actor profile, target profile, device, key, and action. `TestSetValue_DoesNotAuditOwnWrite` keeps ordinary self-service writes out of the audit trail — otherwise volume makes it useless.
- [ ] **Step 2: Run RED, implement, verify GREEN**
Record the *identity* of what changed, never the value: `user_settings.changed` deliberately carries no value because admins receive other accounts' events (`internal/api/handlers/user_settings_events.go:8-13`). The same reasoning applies to a stored audit row. Also audit Forget device and bulk clear.
---
## Phase 3 — Web: the device settings screen
### Task 8: Query hooks for devices and cross-identity settings
**Files:**
- Create: `web/src/hooks/queries/devices.ts`
- Modify: `web/src/hooks/queries/settingValues.ts`
- Modify: `web/src/hooks/queries/keys.ts`
- Modify: `web/src/api/types.ts`
- Test: `web/src/hooks/queries/devices.test.ts`
**Interfaces:**
- Produces: `useMyDevices({ household? })`, `useForgetDevice()`, `useClearDeviceSettings()`.
- Modifies: `SettingIdentity` gains optional `deviceId` and `profileId`; `identityQuery` (`settingValues.ts:60`) serializes them.
- [ ] **Step 1: Extend `SettingIdentity` and `identityQuery`**
Both fields optional, so every existing caller compiles and behaves identically.
- [ ] **Step 2: Fix the cache key — this is a real bug if skipped**
`effectiveSettingsQueryKey` (`settingValues.ts:76`) namespaces by `activeProfileId()`. Reading another profile's or another device's values through the same key would collide with the current device's cache and serve one device's settings as another's. Add `deviceId` and `profileId` to the key, and add a test that two devices' reads occupy distinct entries.
- [ ] **Step 3: Write the device hooks and tests**
Follow the existing `api()` + TanStack Query conventions. Invalidate `[...settingsKeys.all, "values"]` on every mutation, as `useSetSettingValue` does.
```bash
cd web && pnpm vitest run src/hooks/queries/devices.test.ts
```
---
### Task 9: The device list pane
**Files:**
- Create: `web/src/pages/settings/DeviceSettings.tsx`
- Create: `web/src/components/settings/DeviceList.tsx`
- Create: `web/src/components/settings/deviceDisplay.ts`
- Modify: `web/src/pages/SettingsLayout.tsx`
- Modify: `web/src/App.tsx`
- Modify: `web/src/lib/documentTitle.ts`
- Test: `web/src/components/settings/DeviceList.test.tsx`
**Interfaces:**
- Produces: a master-detail layout — searchable list on the left, selected device on the right, stacking to a single column below the `md` breakpoint.
- Produces: `deviceDisplay.ts` — platform icon/label classification and relative-time formatting. Adapt the existing helpers in `web/src/components/admin/deviceOverrides.tsx:40-146` rather than duplicating them; move them here and have the admin page import from the shared module.
- [ ] **Step 1: Add the route and nav entry**
One `NavSection` item under "Account" in `SettingsLayout.tsx` (`NAV_SECTIONS`, from line 52) with `settings: settingIndex(...)` so the entries reach the settings search index, and one `<Route path="devices" …>` in `App.tsx` beside the other settings routes.
- [ ] **Step 2: Build the list with tests**
Rows are fixed height and carry name, last-used, and a changed-count pill; a device with nothing changed shows a dash rather than "0". Group by recency — Using now / This week / Earlier. Search filters by name and platform. Tests: grouping boundaries, the current device is marked, count pill renders a dash at zero, and search matches on platform as well as name.
```bash
cd web && pnpm vitest run src/components/settings/DeviceList.test.tsx
```
---
### Task 10: The device detail pane
**Files:**
- Create: `web/src/components/settings/DeviceSettingGroups.tsx`
- Create: `web/src/lib/deviceSettingGroups.ts`
- Test: `web/src/lib/deviceSettingGroups.test.ts`
- Test: `web/src/components/settings/DeviceSettingGroups.test.tsx`
**Interfaces:**
- Produces: `groupDeviceSettings(keys)` → Picture / Sound / Subtitles / Episodes, derived from each definition's `category` plus a small key→group map for the cases `category` does not separate (`player.*` splits across Picture and Sound).
- Consumes: `ALL_DEVICE_SETTING_KEYS` (`web/src/lib/settingsDisplay.ts:126`) and `RegistrySettingControl` (`web/src/components/settings/RegistrySettingControl.tsx`).
- [ ] **Step 1: Group mapping, with a completeness test**
`TestEveryDeviceKeyIsGrouped` — every key in `ALL_DEVICE_SETTING_KEYS` lands in exactly one group. This is what stops a newly added manifest key from silently vanishing from the UI.
- [ ] **Step 2: Render rows through the shared primitives**
Use `SettingRow` and `SettingsGroup`. No raw keys. "Changed here" badge when a `profile_device` row exists; "Use my setting" clears at `profile_device` — a DELETE, never a copy of the profile value into the device row. Sliders and steppers must round-trip as numbers; do not reuse the admin screen's string round-trip (`web/src/hooks/queries/admin/users.ts:106-145`).
- [ ] **Step 3: Policy-capped rows**
When the effective response carries `constrained_by`, render `permitted_values` only and state the limit and who set it. Never a disabled control with no reason. Test both a capped select and a `locked` constraint.
- [ ] **Step 4: Run**
```bash
cd web && pnpm vitest run src/components/settings/ src/lib/deviceSettingGroups.test.ts
```
---
### Task 11: Remote-device editing and its copy
**Files:**
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
- Modify: `web/src/components/settings/DeviceSettingGroups.tsx`
- Test: `web/src/pages/settings/DeviceSettings.test.tsx`
- [ ] **Step 1: Write to the selected device**
Every mutation passes `deviceId` explicitly rather than relying on the header, so selecting a device and editing it writes that device. Test that editing a non-current device sends its id.
- [ ] **Step 2: Scope and sync copy**
Show the mandated scope sentence once per device — "this device, for your profile only" — not per row. For a non-current device, state that it picks the change up next time it is on. `useSettingValuesRealtime` (`settingValues.ts:245`) already invalidates on `user_settings.changed`, so no polling.
- [ ] **Step 3: Forget and bulk clear, with confirmation**
Both are destructive and both name their target: "Clear all N changes on this device", "Forget this device".
---
## Phase 4 — Web: the household view
### Task 12: Household scope switch and person grouping
**Files:**
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
- Modify: `web/src/components/settings/DeviceList.tsx`
- Test: `web/src/pages/settings/DeviceSettings.household.test.tsx`
**Interfaces:**
- Consumes: `useIsActingAdmin`, `useCurrentProfile`, and `profile.is_primary` — the same rule as `RequirePrimaryOrAdmin` (`web/src/App.tsx:209`) and `isActingAdmin` (`web/src/lib/permissions.ts:19`). Do not write a fourth definition of this predicate.
- Consumes: `GET /profiles` for names, avatars, `is_child`.
- [ ] **Step 1: The switch appears only for the household parent**
Test that a non-primary, non-admin profile never sees it, and that the page still works fully for them in "just mine" mode. The switch is additive; nothing is taken away from anyone.
- [ ] **Step 2: Group devices by person**
Person header with avatar, name, a "You" or "Kid" tag, and a device count; devices nested beneath. When two profiles have registered the same physical TV, say so — "Same TV as yours — separate settings per person" — because that is the single most confusable thing on this screen.
- [ ] **Step 3: Acting-on-behalf copy**
A persistent banner while a sibling's device is selected: "You're changing Robin's settings, not your own." Reset actions name the person: "Use Robin's setting". Test that the banner is absent for one's own devices.
---
### Task 13: Household boundaries in the UI
**Files:**
- Modify: `web/src/components/settings/DeviceSettingGroups.tsx`
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
- Test: `web/src/pages/settings/DeviceSettings.household.test.tsx`
- [ ] **Step 1: Household limits read as limits, and link out**
A value capped by parental controls shows a lock pill naming who set it and links to the profiles screen. This screen never authors a restriction — settings answer "what does this user want", policy answers "what are they allowed to have" (design spec, "Preferences versus restrictions").
- [ ] **Step 2: State the privacy boundary**
A short block: this page shows how Silo is set up per device, not what anyone watched. Viewing history stays private per profile. Test that it renders in household mode.
- [ ] **Step 3: Full check**
```bash
cd web && pnpm run lint && pnpm run format:check && pnpm vitest run
```
---
## Phase 5 — Verification
### Task 14: Cross-cutting checks
- [ ] **Step 1: Full suite**
```bash
make lint
make test
cd web && pnpm run lint && pnpm run format:check
make verify-local-paths
make verify-settings-bindings-all
```
Four Go failures (auth, catalog, jellycompat, notifications) pre-exist on some local Postgres provisioning and are not caused by this work — verify against the branch base before chasing one. `make lint` runs `golangci-lint` over the whole tree while CI runs `--new-from-merge-base`, so expect pre-existing findings that CI will not fail on; do not add to them.
- [ ] **Step 2: Browser verification**
Use the `web-ui-testing` skill against a real backend. Capture, for the PR: the device list at ten or more devices, a device detail pane, a remote-device edit, the household switch, and a policy-capped row. UI changes need screenshots (`CLAUDE.md`, "Pull requests").
- [ ] **Step 3: Manual authorization pass**
With `curl` against a dev server, confirm each refusal returns the intended status and leaks nothing:
- non-primary naming a sibling profile → 403
- primary with an unverified PIN → 403
- a profile id from another account → 404
- a device id belonging to another profile → 404
- [ ] **Step 4: Cross-repo follow-up**
The two identity widenings are additive server capabilities that Apple and Android may adopt later; nothing in those clients breaks without a change. Note in the PR whether follow-up issues are wanted, per `CLAUDE.md` "Multi-repo".
---
## Out of scope, and why
- **Renaming a device.** `device_name` is client-reported and re-registration overwrites it, so a user-set name needs a separate column and a precedence rule. Worth doing; not part of this plan.
- **"Copy my settings from another device".** Appealing, but it writes up to 30 keys in one request, which is the argument for the batch mutations endpoint the design spec names (`POST /api/v1/settings/mutations`) and which does not exist. Build that first.
- **Transferring the primary designation.** `is_primary` is assigned implicitly to the first profile created (`internal/userstore/pgstore/profiles.go:60-73`) and cannot be moved. The household view makes that visible, so it likely needs to exist — as its own issue.
- **Watch history in the household view.** A different privacy question that a settings screen should not quietly answer.
- **Admin device screen rework.** Once users self-serve, `/admin/devices` can go on being a fleet console. Grouping its rows by what they affect and hiding raw keys behind a disclosure is worth doing separately.
## Adjacent gaps found while planning
Same shape as this work, but not blockers — each deserves its own issue:
- `PUT`/`DELETE /profiles/{id}/avatar` (`internal/api/handlers/profile_avatars.go:202`, `:290`) apply no household guard and no self-check: any profile can change or delete any sibling's avatar, including the primary's.
- `GET /profiles` (`internal/api/handlers/profiles.go:263`) returns every sibling's resolved preferences, `has_pin`, `is_child`, content rating and library restrictions to any profile on the account.
- `POST /profiles/{id}/verify-pin` (`internal/api/handlers/profiles.go:701`) is open to any authenticated user of the account for any profile id, with no rate limiting in the handler.
@@ -1,484 +0,0 @@
# Emailed Invitations &amp; Server-Driven Onboarding — Design Spec
**Date:** 2026-07-27
**Status:** Draft — for review
**Mockups:** `docs/design/invite-onboarding.html`
**Surfaces:** silo-server, silo-server/web, silo-android, silo-apple
## Problem
Two gaps, one journey.
**Getting in.** Today an admin creates a multi-use `invite_codes` row and hands
the string to someone out-of-band. That person visits `/signup`, invents a
username, retypes their email, retypes the code, and lands on an empty home
screen. Nothing is scoped ahead of time — library access, group, and role are
whatever the defaults are, so the admin edits the user afterward. There is no
per-person revocation: disabling a code disables it for everyone who has it.
**Staying.** Silo has a set of features most self-hosted media servers do not
ship at all — watch together, media requests, watchlists with arrival
notifications, taste-based recommendations, a release calendar, per-library and
per-series playback overrides, subtitle appearance that follows the profile.
A new user discovers none of it. The only onboarding that exists is the
taste-seed picker (`web/src/pages/TasteSeed.tsx`), which is web-only and
covers exactly one feature.
This spec covers both: a personal, pre-provisioned invitation delivered by
email, and a first-run tour whose content the server owns so all three client
platforms stay in sync.
## Non-goals
- Replacing `invite_codes`. Shareable codes stay exactly as they are for the
"drop a code in Discord" case. This adds a second, complementary mechanism.
- Self-service password reset. Adjacent and worth doing, but a separate
concern with its own threat model. The token table here is deliberately
shaped so a reset flow can reuse it later (see "Future work").
- SSO/OAuth invitations. Plugin auth providers own their own account
provisioning; an invitation targets local credentials only.
- An admin-authorable tour editor. The manifest is server-owned but built in
Go, not user-editable. Admins get on/off toggles, not a CMS.
---
# Part 1 — Invitations
## Model
One new table. An invitation is a **capability token bound to one email
address**, carrying the access decisions the admin already made.
```sql
CREATE TABLE public.invitations (
id bigserial PRIMARY KEY,
email citext NOT NULL,
token_hash text NOT NULL UNIQUE,
-- Pre-bound access, applied verbatim at accept time.
role text NOT NULL DEFAULT 'user',
access_group_id bigint REFERENCES public.access_groups(id) ON DELETE SET NULL,
library_ids integer[], -- NULL = all libraries
create_profile boolean NOT NULL DEFAULT true,
show_tour boolean NOT NULL DEFAULT true,
note text NOT NULL DEFAULT '',
-- Lifecycle.
invited_by bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
accepted_user_id bigint REFERENCES public.users(id) ON DELETE SET NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX invitations_email_idx ON public.invitations (email);
-- At most one live invitation per address. Re-inviting supersedes rather
-- than accumulating parallel valid tokens for the same person.
CREATE UNIQUE INDEX invitations_one_pending_idx ON public.invitations (email)
WHERE accepted_at IS NULL AND revoked_at IS NULL;
```
Token handling mirrors the email-verification flow that already exists in
`internal/notifications/email_address.go`: 32 random bytes,
`base64.RawURLEncoding` for the URL, **SHA-256 hex stored at rest**. The raw
token exists in the sent email and nowhere else — a database dump does not
yield usable invitation links.
### Why no user row until accept
An unaccepted invitation creates nothing in `users`. A typo'd address cannot
squat a username, cannot appear in the user list as a permanent ghost, and
cannot be counted against any seat/limit logic added later. The
`invitations_one_pending_idx` partial unique index is what keeps "resend"
honest: it supersedes, so a forwarded copy of the old link stops working.
### Email as username
The invitee sets only a password. `username` is set to the invitation's email
address. This works today without a schema change: `users.username` and
`users.email` are both `citext` with unique constraints (migration
`165_case_insensitive_username_email.sql`), so `marco@example.com` is a legal
username and matches case-insensitively.
One behavior change is required for this to feel right:
> **`LocalProvider.Authenticate` must accept an email address in the username
> field.** `internal/auth/provider.go:56` calls `users.GetByUsername` only. It
> gains an email fallback: if the username lookup misses **and** the input
> parses as an email address, retry via `GetByEmail`.
This is strictly additive — existing username logins are unaffected, and there
is no ambiguity risk because both columns are globally unique across the same
identity space. Without it, someone invited by email who later types their
address on the phone app gets "invalid credentials" and has no way to discover
that their username happens to be identical to the thing they just typed.
Login rate limiting already covers this endpoint (`AuthEndpointHandler("login")`),
so the extra lookup does not open an enumeration channel that the single lookup
did not already have.
## Endpoints
Admin, under the existing `/api/v1/admin` tree (admin-gated, same as
`/admin/invite-codes`):
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/admin/invitations` | List, newest first, with derived status. |
| `POST` | `/admin/invitations` | Create + send. Returns the row and, when email is off, the raw claim URL. |
| `POST` | `/admin/invitations/{id}/resend` | Supersede: revoke, mint fresh token, resend. |
| `DELETE` | `/admin/invitations/{id}` | Revoke. Idempotent. |
Public, under `/api/v1`, unauthenticated, **rate-limited via the existing
`deps.RateLimitMW.AuthEndpointHandler`** alongside login/signup:
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/invitations/{token}` | Claim-screen data only: inviter display name, email, expiry, server name. |
| `POST` | `/invitations/{token}/accept` | Create the account, redeem, return a session token pair. |
`accept` returns the **same `loginResponse` shape as signup**
(`buildLoginResponse`), so every client reuses its existing session plumbing
rather than growing a parallel auth path.
### Status is derived, not stored
`Pending | Accepted | Expired | Revoked` is computed from
`accepted_at`/`revoked_at`/`expires_at` at read time. No status column, no
background job to expire rows, no possibility of the column disagreeing with
the timestamps.
### Accept is one transaction
Create user → apply pre-bound access → create the default profile → mark the
invitation accepted. `auth.AccountProvisioner.CreateAccount` already handles
user-plus-profile with rollback-on-profile-failure
(`internal/auth/account_provisioner.go`), so this reuses it and adds the
invitation update. A concurrent double-submit loses on the
`accepted_at IS NULL` predicate and returns "already used".
## Email
Composed in a new `internal/invitations` package using `internal/mail`'s
`RenderLayout`, `EmailParagraph`, and `EmailButton`, exactly as
`composeVerificationEmail` does. Same SMTP settings, same diagnostics, same
branded shell.
Link base resolution reuses the established precedence:
`notifications.email.external_url` → server public URL. When neither is set,
`POST /admin/invitations` still creates the row and returns the claim URL with
a `email_sent: false` flag; the admin UI shows **"Copy invite link"** instead
of pretending mail went out. Failing loudly beats a silent drop.
The email states the sign-in address explicitly. It is the one fact the person
will need again when they install the phone app, and the one they would
otherwise guess wrong.
## Deep links
The claim URL is `https://<server>/invite/<token>`.
- **Web** — a new public route, sibling to `/signup` and `/activate` in
`web/src/App.tsx`.
- **Android** — the `silo` scheme already routes `device`, `item`, `play`, and
`downloads` hosts in `AndroidManifest.xml`. Add an `invite` host plus an
`https` App Link on the server's domain, parsed by the existing
`ContentDeepLinkRoutes` machinery.
- **Apple** — `onOpenURL` already posts to a notification the root view
consumes and queues until auth settles (`iOSApp.swift`). Add an `invite`
case; universal links need the server to serve
`/.well-known/apple-app-site-association`, which is new work.
Crucially, the deep link carries **both the server URL and the token**, so the
app skips its "which server?" step entirely — that is most of the friction in
onboarding someone onto a self-hosted service.
## Household setup ("Who's watching?")
Between the password and the tour sits a household step: profiles for
everyone on the couch — a spouse, kids, grandparents. The journey indicator
reads **Invite → Password → Household**.
**This step needs no new backend.** `POST /api/v1/profiles` already carries
everything the screen sets: name, preset avatar, PIN, `is_child`,
`max_content_rating`, and per-profile library restrictions
(`createProfileRequest`, `internal/api/handlers/profiles.go:50`). The work is
purely presentational — surfacing the existing profile model at the moment it
matters instead of leaving it buried in Settings → Profiles.
Design decisions:
- **Profiles are not logins, and the screen says so.** One account, one
password, many viewers — several profiles per `user_id` is the existing
model. The copy heads off the "do I need to invite my wife separately?"
question. (If she *should* have her own login — her own password, her own
email — that's a second invitation, and the admin decides which they want.)
- **The kids toggle is a preset, not a form.** Flipping `is_child` on reveals
a rating ceiling (default PG) and the library picker. Off, they stay hidden.
A grandparent profile is just a plain profile — no ceiling, no extra fields.
- **PIN guidance over PIN plumbing.** The common mistake is pinning the kid's
profile; the useful move is pinning the adults' so the kids can't wander in.
One line of copy under the toggle: "Usually for the parents' profiles, not
the kids' — it keeps the kids out of yours."
- **"Just me for now" is a first-class exit.** It creates nothing, and the
tour later includes a household `feature_card` reminding them profiles
exist. No nagging.
- **Restriction composition is unchanged.** A profile's rating ceiling and
library restrictions layer under the account's invitation-bound access via
the existing strictest-layer-wins resolver. The invitee cannot grant a
profile more than the account received.
- **Taste-seed stays per-profile.** Each created profile gets its own
taste-seed state, and the picker already filters by the active profile's
rating ceiling — Emma's picker shows kid-appropriate titles.
The invitation's `create_profile` flag becomes moot when this screen exists on
a surface: the first tile ("You") *is* the default profile, pre-named from the
email's local part and renameable inline. The flag stays in the schema for
headless accepts (a client too old to render the household step still gets a
usable account).
---
# Part 2 — Server-driven onboarding
## Why the server owns it
Three clients, three release cadences, and one of them has a multi-day review
queue. If the tour lives in client code:
- Adding a stop for a new feature means three PRs and waiting on Apple.
- A server with requests disabled still ships a requests stop, so the tour
advertises a 404.
- Fixing an awkward sentence is a store release.
With a server manifest, the tour is filtered per-server and per-profile before
it reaches the client, and copy changes are a server deploy.
## Contract
```
GET /api/v1/onboarding/flow?surface=web|phone|tv
```
Returns an ordered step list for **this server** and **this profile**, with
disabled features already filtered out:
```json
{
"version": 1,
"tour_id": "core-2026-07",
"steps": [
{
"id": "welcome",
"kind": "welcome",
"title": "Silo isn't quite like the others",
"body": "You've probably used Plex or Jellyfin...",
"illustration": "welcome"
},
{
"id": "playback-quality",
"kind": "setting_choice",
"title": "Pick a quality ceiling now, change it anywhere",
"body": "Silo won't burn your data plan guessing...",
"setting": {
"target": "profile_field",
"key": "quality_preference",
"control": "segmented",
"options": [
{"value": "auto", "label": "Auto"},
{"value": "1080p", "label": "1080p"},
{"value": "4k", "label": "4K"}
],
"default": "auto"
}
},
{
"id": "watch-together",
"kind": "feature_card",
"title": "Same movie, different couches",
"body": "Start a room, send the link...",
"illustration": "watch-together",
"action": {"label": "Show me", "route": "/rooms/join"}
},
{
"id": "handoff-taste",
"kind": "handoff",
"title": "Pick a few favorites",
"route": "/taste-seed"
}
]
}
```
### Step kinds
| `kind` | Renders | Notes |
| --- | --- | --- |
| `welcome` | Framing card | Always first. |
| `feature_card` | Title, body, illustration key, optional route action | The bulk of the tour. |
| `setting_choice` | A control that writes a real setting | See below. |
| `spotlight` | Highlight of a named UI anchor | Web/tablet only; phone and TV skip. |
| `handoff` | Ends the tour by routing somewhere | Hands off to `/taste-seed`. |
**Unknown kinds are skipped silently.** This is the whole point of the
versioned contract: the server may add a kind, and an older client drops it
rather than crashing or rendering a blank card. Clients send their surface;
the server omits steps that surface cannot render (spotlights on TV, text
entry on TV).
**Illustrations ship with the client**, keyed by a string. The server never
sends image URLs — assets stay native, sized correctly per platform, and work
offline.
### `setting_choice` writes real settings
The tour is not a slideshow that ends with "now go configure it yourself" — by
the last step the account is genuinely set up. It writes through the *existing*
APIs, so there is no new persistence and no risk of the tour and the settings
screens disagreeing.
Silo has three distinct write targets, and the step must say which one it
means. This matters: playback quality is **not** a settings key — it is a
column on the profile, written via `PUT /profiles/{id}` (see
`saveProfileField` in `web/src/pages/settings/PlaybackSettings.tsx:114`).
Collapsing all three into one "key" field would produce a contract that cannot
express the most important step in the tour.
| `target` | Written via | Used for |
| --- | --- | --- |
| `profile_field` | `PUT /api/v1/profiles/{id}` | `quality_preference`, `subtitle_language`, `subtitle_mode`, `auto_skip_intro`, `auto_skip_credits`, `auto_skip_recap` |
| `setting` | `PUT /api/v1/settings/{key}` | `playback.auto_play_next`, `subtitle_appearance`, overlay and home keys |
| `device_setting` | `PUT /api/v1/settings/device/{key}` | Anything that should differ per device (a quality ceiling on cellular) |
The server only emits a `setting_choice` the current profile may actually
write, so a child profile never gets a step it would be refused on.
### Progress
```
GET /api/v1/onboarding/state → {"tour_id": "...", "completed_at": ..., "skipped_at": ..., "last_step": "..."}
POST /api/v1/onboarding/progress → {"tour_id": "...", "last_step": "...", "completed": bool, "skipped": bool}
```
State is stored **per profile, server-side**, in the existing per-user SQLite
store (`internal/userdb`) — no new Postgres table, and no join from Postgres to
profile storage (profiles may live in per-user SQLite, which is why the
notification tables denormalize `user_id` rather than joining).
Note that `user_settings` is keyed by `key` alone and is therefore
**account-wide, not per-profile** (`internal/userdb/schema.go:185`). Storing
tour state there would mean one profile finishing the tour silences it for the
whole household. Two options, in preference order:
1. **A dedicated `profile_onboarding` table** in the userdb schema — keyed
`(profile_id, tour_id)` with `last_step`, `completed_at`, `skipped_at`.
Explicit, queryable, and the right shape if contextual follow-up tours
(multiple `tour_id`s) happen later. Requires a userdb schema version bump
(`internal/userdb/migrate.go`).
2. A profile-prefixed key in `user_settings` (`onboarding.<profile_id>.state`).
No schema change, but it abuses a flat key space and makes "show me every
profile that skipped" a `LIKE` scan.
**Recommendation: option 1.** The userdb schema already carries per-profile
tables (`subtitle_preferences`, `series_playback_preferences`), so this follows
the established pattern rather than working around it.
Two consequences worth stating plainly:
- Finishing the tour on the web means the phone does not ask again.
- Skipping is honored everywhere, permanently, until the user replays it from
Settings → Personalize.
The current taste-seed dismissal is a **localStorage** flag
(`web/src/lib/tasteSeed.ts`), which is why it re-prompts on every new browser
and never syncs to the apps. The tour deliberately does not repeat that
mistake; taste-seed's own flag can migrate to the same mechanism later.
### Gating
Steps are filtered against what the server actually has on:
| Step | Gated on |
| --- | --- |
| Requests | `requests_enabled` (`internal/requests`) |
| Watch together | watch-together enabled |
| Notifications | `mail.Sender.Enabled()` or push configured |
| Calendar | any library with scheduled content |
| Recommendations | recommendations enabled |
| Subtitles / quality / overlays | always (core playback) |
A server with nothing but a movie library gets a short, honest tour rather
than a tour of things it does not do.
## Reach
The tour is **not invite-only**. Anyone whose profile has no completion record
gets it: existing accounts, code-signup accounts, and additional profiles on
an existing account. The invitation just makes the entrance nicer. The
`show_tour` flag on an invitation only suppresses it for the deliberate case
(re-inviting someone who already knows the product).
---
## Security notes
- **Tokens at rest are hashed.** A DB dump yields no usable links.
- **Enumeration.** `GET /invitations/{token}` returns identical 404s for
unknown, expired, revoked, and accepted tokens. The claim screen distinguishes
them only via the accept path's error codes, which are rate-limited. 32 bytes
of entropy makes guessing irrelevant.
- **Privilege ceiling.** An admin cannot mint an invitation granting more than
they hold. Role `admin` on an invitation requires the inviter be an admin —
enforced at the handler, not just hidden in the UI.
- **Access composition is unchanged.** Pre-bound `library_ids` and
`access_group_id` feed the existing restriction-only resolver
(`docs/superpowers/specs/2026-07-02-access-groups-design.md`): the strictest
layer still wins. An invitation is a convenience for setting the initial
values, never a bypass.
- **Mail as an oracle.** Invitation sending is admin-only, so it is not a
spray vector the way unauthenticated verification sends would be. Resend
inherits the same admin gate.
- **Note field** is admin-authored and rendered escaped in HTML mail and raw in
the text part — same handling as every other user-supplied value in
`internal/mail`.
## Risks and tradeoffs
**Email-as-username changes the login lookup.** Mitigated by making it a
fallback (username first, email only on miss and only when the input parses as
an address), so existing behavior is untouched. Worth a test that asserts a
user whose *username* is `a@b.com` and a different user whose *email* is
`a@b.com` cannot both exist — the unique constraints already prevent it, but
the test documents why the fallback is unambiguous.
**A server-driven tour couples client releases to a contract.** Mitigated by
"unknown kinds are skipped" and an explicit `version`. The failure mode of a
client that is too old is a shorter tour, not a broken one.
**Apple universal links need server-side association hosting.** The `silo://`
custom scheme works immediately; universal links are a follow-up that improves
the experience but does not gate the feature.
**Scope.** This is a three-repo change. The server plus web is independently
shippable and useful on its own; the clients can follow without the server work
being wasted in the meantime.
## Future work
- Password reset reusing the same token shape and mail plumbing.
- Self-serve "invite a friend" for household primaries, bounded by an
admin-set quota — the table already carries `invited_by`.
- Migrating taste-seed's localStorage dismissal to server-side onboarding
state.
- Contextual follow-ups ("you've watched 5 things — did you know about
watchlists?") reusing the same manifest with a different `tour_id`.
## Open questions
1. **Invitation expiry** — 7 days assumed. Admin-configurable server setting,
or fixed?
2. **Tour replay entry point** — Settings → Personalize is assumed. Confirm
that is the right home rather than a Help menu.
3. **`tour_id` versioning** — when the tour changes materially, should users
who completed `core-2026-07` be re-prompted for `core-2027-01`, or does
completion mean completion forever?
@@ -1,86 +0,0 @@
# Transcode Resolution Clamp Design
## Problem
When video encoding is requested for a 2160p file while 4K transcoding is
disabled, the playback handler selects a lower-resolution alternate file.
Today it continues using the target resolution supplied for the original file.
That lets a 1080p alternate reach FFmpeg with a 2160p target, causing an
unnecessary upscale and making the persisted playback activity claim that
2160p was delivered.
Production logs confirmed this exact path with a 1080p H.264 alternate and a
QSV `scale_vaapi` filter targeting 2160 lines.
## Decision
The server owns the output-resolution invariant. After all alternate-file and
probe selection has completed, and before planning or starting any local or
remote transport, it will normalize an encoded-video target so it cannot
exceed the effective source file's resolution.
The normalization rules are:
- Do not apply the new clamp to video-copy requests; preserve their existing
downstream recipe normalization.
- Preserve an empty or unrecognized requested resolution unchanged.
- Preserve an empty or unrecognized effective source resolution unchanged.
- Preserve targets at or below the effective source resolution.
- Clamp a recognized target above a recognized effective source resolution to
the effective source resolution.
- Do not alter target bitrate. The observed 10 Mbps value is already a valid
1080p-high encoding target and is not the demonstrated defect.
Recognized resolutions are the same tiers already supported by the transcode
filter builders: 2160p, 1080p, 720p, 480p, 420p, and 328p.
## Architecture and Data Flow
A small pure helper in `internal/api/handlers/playback.go` compares requested
and effective-source resolution tiers. `HandleStartTranscode` calls it once
after the 4K alternate guard has selected and probed the effective file.
The normalized value replaces `req.TargetResolution`. Existing downstream
paths then automatically use one consistent value for:
- transcode-node planning and requests;
- integrated/local `playback.TranscodeOpts`;
- playback-session replacement and activity reporting;
- later transport reconstruction and restart recipes.
This boundary is preferred over a web-only correction because every client can
call the endpoint, and over an FFmpeg-only correction because session state and
remote transcode requests must remain truthful.
## Compatibility and Error Handling
The change is additive enforcement inside the existing `/api/v1` endpoint. It
does not change request or response shapes, status codes, codec selection,
alternate-file selection, or copy-video behavior. Unsupported resolution
strings retain their existing behavior rather than being silently reinterpreted.
No server settings, migrations, client changes, or production deployment
changes are part of this work.
## Verification
Tests will establish:
1. A 2160p encode request switched to a 1080p alternate reaches the remote
transcode node as 1080p and persists 1080p in the playback session.
2. The same normalized request reaches the local transcode option boundary.
3. Targets already below the source remain unchanged.
4. Copy-video and unknown-resolution requests remain unchanged.
5. Existing playback-handler and playback-package suites remain green.
The regression must fail against unmodified main before the implementation is
added.
## Security and Operational Impact
The server currently permits an authenticated user with transcoding permission
to amplify compute and bandwidth by requesting a recognized output larger than
the effective source. The clamp removes that unnecessary amplification while
retaining all authorized transcoding behavior.
Rollback is a single code commit. No data rollback is required.
+3 -3
View File
@@ -12,7 +12,7 @@ require (
github.com/jackc/pgx/v5 v5.9.2
github.com/mattn/go-sqlite3 v1.14.34
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.45.0
@@ -33,7 +33,6 @@ require (
github.com/open-policy-agent/opa v1.18.2
github.com/pgvector/pgvector-go v0.3.0
github.com/pressly/goose/v3 v3.27.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/tetratelabs/wazero v1.12.0
github.com/wneessen/go-mail v0.7.3
github.com/zishang520/socket.io/v2 v2.5.0
@@ -79,8 +78,9 @@ require (
github.com/oklog/run v1.1.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.11.1 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
+2 -2
View File
@@ -238,8 +238,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA=
github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
-34
View File
@@ -1,34 +0,0 @@
package access
import (
"context"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// PreferredMetadataLanguage resolves catalog.metadata_language canonically for
// one profile: the stored profile-scope value, else the contract default. The
// legacy user_profiles.preferred_metadata_language column is deliberately not
// consulted — it migrated to the canonical store, and reading both would let
// them disagree.
//
// Resolution is unconstrained on purpose. The manifest gives this key no
// constrained_by because the policy input that could constrain it
// (profile_preferred_metadata_language) is populated from this very
// preference; a constraint here would be circular. See the key's notes in
// contracts/settings/v1/manifest.json.
//
// A resolution failure degrades to "" — the contract default, meaning "inherit
// the library's metadata language" — rather than failing scope resolution: the
// language is a presentation preference, not an access boundary. The failure
// itself is logged, though: before the cutover this value rode on the profile
// row whose load failure was a hard error, and a store outage that silently
// degrades every profile's metadata language would otherwise be
// indistinguishable from "nobody set a preference".
func PreferredMetadataLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
if store == nil || profileID == "" {
return ""
}
resolved, _ := resolveCanonicalViewerPreferences(ctx, store, profileID)
return resolved.preferences.PreferredMetadataLanguage
}
-152
View File
@@ -1,152 +0,0 @@
package access
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// settingStoreStub only answers the one method resolution reaches; the
// embedded nil interface panics on anything else, which is the point — this
// path must not touch the rest of the store.
type settingStoreStub struct {
userstore.UserStore
rows []userstore.SettingValue
err error
}
func (s settingStoreStub) ListSettingValuesForResolution(
context.Context, userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error) {
return s.rows, s.err
}
type capturingLogHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *capturingLogHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *capturingLogHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
h.records = append(h.records, r)
h.mu.Unlock()
return nil
}
func (h *capturingLogHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *capturingLogHandler) WithGroup(string) slog.Handler { return h }
func (h *capturingLogHandler) snapshot() []slog.Record {
h.mu.Lock()
defer h.mu.Unlock()
return append([]slog.Record(nil), h.records...)
}
func captureLogs(t *testing.T) *capturingLogHandler {
t.Helper()
handler := &capturingLogHandler{}
prev := slog.Default()
slog.SetDefault(slog.New(handler))
t.Cleanup(func() { slog.SetDefault(prev) })
return handler
}
// TestPreferredMetadataLanguageLogsStoreFailures pins the operator signal: the
// value deliberately degrades to "" on a store failure, but before the cutover
// it rode on the already-loaded profile row where a load failure was a hard
// error. A silent degrade would make transient pool exhaustion — or a
// persistently broken query path — indistinguishable from "no preference".
func TestPreferredMetadataLanguageLogsStoreFailures(t *testing.T) {
handler := captureLogs(t)
store := settingStoreStub{err: errors.New("connection pool exhausted")}
if got := PreferredMetadataLanguage(context.Background(), store, "profile-1"); got != "" {
t.Fatalf("degraded value = %q, want \"\"", got)
}
records := handler.snapshot()
if len(records) == 0 {
t.Fatal("a store failure resolved to the default with no log output")
}
record := records[0]
if record.Level < slog.LevelWarn {
t.Errorf("logged at %v, want at least WARN", record.Level)
}
var loggedError, loggedProfile bool
record.Attrs(func(a slog.Attr) bool {
switch a.Key {
case "error":
loggedError = strings.Contains(a.Value.String(), "connection pool exhausted")
case "profile_id":
loggedProfile = a.Value.String() == "profile-1"
}
return true
})
if !loggedError {
t.Errorf("log %q does not carry the store error", record.Message)
}
if !loggedProfile {
t.Errorf("log %q does not name the profile", record.Message)
}
}
// TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored: the healthy
// no-preference answer must not spam the log.
func TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored(t *testing.T) {
handler := captureLogs(t)
if got := PreferredMetadataLanguage(context.Background(), settingStoreStub{}, "profile-1"); got != "" {
t.Fatalf("no-preference value = %q, want \"\"", got)
}
if records := handler.snapshot(); len(records) != 0 {
t.Errorf("healthy resolution logged %d records, want none", len(records))
}
}
func TestResolveViewerPreferencesIncludesMetadataLanguageOverrides(t *testing.T) {
store := settingStoreStub{rows: []userstore.SettingValue{
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`[]`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`"en"`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguageOverrides, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`{"nor":"x-silo-original","JA":"de"}`),
},
}}
preferences := ResolveViewerPreferences(context.Background(), store, "profile-1")
if preferences.PreferredMetadataLanguage != "en" {
t.Fatalf("fallback language = %q, want en", preferences.PreferredMetadataLanguage)
}
want := map[string]string{"no": OriginalMetadataLanguage, "ja": "de"}
if len(preferences.MetadataLanguageOverrides) != len(want) {
t.Fatalf("metadata overrides = %#v, want %#v", preferences.MetadataLanguageOverrides, want)
}
for source, target := range want {
if preferences.MetadataLanguageOverrides[source] != target {
t.Errorf("metadata override %q = %q, want %q", source, preferences.MetadataLanguageOverrides[source], target)
}
}
}
+14 -31
View File
@@ -10,11 +10,8 @@ import (
"github.com/Silo-Server/silo-server/internal/userstore"
)
// settingKeyDisabledLibraryIDs is the legacy account-wide user-settings key
// that stored a JSON array of library IDs the user had chosen to hide. It is
// read only as a fallback now: the setting moved to the profile-scoped
// canonical key ui.disabled_library_ids, and the legacy write endpoint no
// longer accepts this key.
// settingKeyDisabledLibraryIDs is the user-settings key that stores a JSON
// array of library IDs the user has chosen to hide.
const settingKeyDisabledLibraryIDs = "disabled_library_ids"
// UserRepository loads account-level access settings.
@@ -75,7 +72,6 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
return Scope{}, fmt.Errorf("opening user store for %d: %w", input.UserID, err)
}
preferences := ResolveViewerPreferences(ctx, store, input.ProfileID)
if input.ProfileID != "" {
profile, err := store.GetProfile(ctx, input.ProfileID)
if err != nil {
@@ -87,8 +83,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.MaxContentRating = profile.MaxContentRating
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
scope.PreferredMetadataLanguage = preferences.PreferredMetadataLanguage
scope.MetadataLanguageOverrides = preferences.MetadataLanguageOverrides
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
if err != nil {
@@ -97,8 +92,8 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.ProfileVerified = verified
}
// Apply the profile's disabled library IDs setting.
disabled := preferences.DisabledLibraryIDs
// Apply user-level disabled library IDs setting.
disabled := DisabledLibraryIDs(ctx, store)
if len(disabled) > 0 {
if scope.AllowedLibraryIDs != nil {
// Restricted user: subtract disabled IDs from the allowed set.
@@ -144,29 +139,17 @@ func VerifyProfileForRequest(
return profileVerified, nil
}
// DisabledLibraryIDs resolves the libraries the acting profile has hidden from
// its own browsing: the canonical profile-scoped ui.disabled_library_ids row,
// else the legacy account-wide disabled_library_ids setting.
//
// The canonical row is what the web writes since the settings cutover — the
// legacy endpoint rejects the unregistered key, so an account-key read alone
// would silently ignore every edit made after the cutover. The legacy fallback
// stays because the one-time backfill only ran on stores that existed when it
// shipped: a store restored from a pre-backfill snapshot still carries its
// hidden libraries only in the account key, and dropping the fallback would
// unhide them. A stored canonical row always wins, so the fallback can never
// override a post-cutover edit.
func DisabledLibraryIDs(ctx context.Context, store userstore.UserStore, profileID string) []int {
return ResolveViewerPreferences(ctx, store, profileID).DisabledLibraryIDs
}
// parseLibraryIDList decodes a JSON library-id array, dropping anything that
// is not a positive id. Malformed JSON reads as an empty list.
func parseLibraryIDList(raw json.RawMessage) []int {
var ids []int
if err := json.Unmarshal(raw, &ids); err != nil {
// DisabledLibraryIDs reads and parses the disabled_library_ids user setting.
func DisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
if err != nil || raw == "" {
return nil
}
var ids []int
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return nil
}
// Filter out invalid values.
n := 0
for _, id := range ids {
if id > 0 {
+1 -210
View File
@@ -2,14 +2,11 @@ package access
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -42,10 +39,6 @@ type stubStore struct {
profile *userstore.Profile
err error
settings map[string]string
// settingValues are the canonical setting rows the resolver may read
// through ListSettingValuesForResolution. Scope matching is the
// resolver's job, so the stub returns them unfiltered.
settingValues []userstore.SettingValue
}
func (s stubStore) CreateProfile(context.Context, userstore.Profile) error { panic("unused") }
@@ -223,19 +216,7 @@ func (s stubStore) GetSetting(_ context.Context, key string) (string, error) {
}
return "", nil
}
func (s stubStore) SetSetting(context.Context, string, string) error { panic("unused") }
func (s stubStore) GetOnboardingState(context.Context, string, string) (*userstore.OnboardingState, error) {
panic("unused")
}
func (s stubStore) UpsertOnboardingState(context.Context, userstore.OnboardingState) error {
panic("unused")
}
func (s stubStore) GetJellycompatDisplayPrefs(context.Context, string, string) (string, error) {
panic("unused")
}
func (s stubStore) SetJellycompatDisplayPrefs(context.Context, string, string, string) error {
panic("unused")
}
func (s stubStore) SetSetting(context.Context, string, string) error { panic("unused") }
func (s stubStore) DeleteSetting(context.Context, string) error { panic("unused") }
func (s stubStore) ListSettings(context.Context) ([]userstore.SettingEntry, error) { panic("unused") }
func (s stubStore) GetDeviceSetting(context.Context, string, string, string) (*userstore.DeviceSettingEntry, error) {
@@ -290,42 +271,6 @@ func (s stubStore) UpsertLibraryPlaybackPreference(context.Context, userstore.Li
func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int) error {
panic("unused")
}
func (s stubStore) GetSettingValue(context.Context, userstore.SettingIdentity) (*userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
return s.settingValues, nil
}
func (s stubStore) ListAllSettingValues(context.Context) ([]userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) UpsertSettingValue(context.Context, userstore.SettingIdentity, json.RawMessage) (*userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValue(context.Context, userstore.SettingIdentity) (bool, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForProfile(context.Context, string) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForDevice(context.Context, string, string) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForLibrary(context.Context, int) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForSeries(context.Context, string) (int64, error) {
panic("unused")
}
func (s stubStore) GetSettingMutation(context.Context, string) (*userstore.SettingMutationRecord, error) {
panic("unused")
}
func (s stubStore) PutSettingMutation(context.Context, userstore.SettingMutationRecord) (userstore.SettingMutationRecord, bool, error) {
panic("unused")
}
func (s stubStore) DeleteExpiredSettingMutations(context.Context, time.Time) (int64, error) {
panic("unused")
}
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
resolver := NewResolver(
@@ -461,93 +406,6 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
}
}
func TestResolver_DisabledLibraries_CanonicalRowWins(t *testing.T) {
// The canonical profile-scoped ui.disabled_library_ids row wins; the
// legacy account key carries a decoy value that must not be read once a
// canonical row exists.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[9]"},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`[3,5]`),
}},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if len(scope.DisabledLibraryIDs) != 2 || scope.DisabledLibraryIDs[0] != 3 || scope.DisabledLibraryIDs[1] != 5 {
t.Fatalf("DisabledLibraryIDs = %v, want canonical [3 5]", scope.DisabledLibraryIDs)
}
}
func TestResolver_DisabledLibraries_CanonicalNullClearsLegacy(t *testing.T) {
// A stored null spells "no hidden libraries" and still wins over the
// legacy key: the row exists, so the profile has decided.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[9]"},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`null`),
}},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if len(scope.DisabledLibraryIDs) != 0 {
t.Fatalf("DisabledLibraryIDs = %v, want empty", scope.DisabledLibraryIDs)
}
}
func TestResolver_DisabledLibraries_ProfileIsolation(t *testing.T) {
// Profile A's canonical hidden-library list must not leak into profile B:
// with no canonical row of its own and no legacy key, B hides nothing.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-b"},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-a",
},
Value: json.RawMessage(`[3,5]`),
}},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-b"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if len(scope.DisabledLibraryIDs) != 0 {
t.Fatalf("DisabledLibraryIDs = %v, want empty for the other profile", scope.DisabledLibraryIDs)
}
}
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
@@ -569,73 +427,6 @@ func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
}
}
func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
// The canonical catalog.metadata_language row wins; the legacy profile
// column carries a decoy value that must no longer be read.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{
ID: "prof-1",
PreferredMetadataLanguage: "fr",
},
settingValues: []userstore.SettingValue{
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`"de"`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguageOverrides,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`{"no":"x-silo-original"}`),
},
},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if scope.PreferredMetadataLanguage != "de" {
t.Fatalf("PreferredMetadataLanguage = %q, want canonical value %q", scope.PreferredMetadataLanguage, "de")
}
if got := scope.MetadataLanguageOverrides["no"]; got != OriginalMetadataLanguage {
t.Fatalf("MetadataLanguageOverrides[no] = %q, want %q", got, OriginalMetadataLanguage)
}
}
func TestResolver_MetadataLanguageIgnoresLegacyColumn(t *testing.T) {
// A profile with only the legacy column value falls to the contract
// default ("" — inherit), proving the column is no longer read.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{
ID: "prof-1",
PreferredMetadataLanguage: "fr",
},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if scope.PreferredMetadataLanguage != "" {
t.Fatalf("PreferredMetadataLanguage = %q, want contract default \"\"", scope.PreferredMetadataLanguage)
}
}
func TestResolver_AppliesGroupPolicy(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{
-3
View File
@@ -12,9 +12,6 @@ type Scope struct {
// PreferredMetadataLanguage is the profile's metadata (presentation)
// language; "" inherits the library's metadata language.
PreferredMetadataLanguage string
// MetadataLanguageOverrides maps a media item's canonical original-language
// code to the profile's target metadata language for that source.
MetadataLanguageOverrides map[string]string
PolicyRevision int64
ProfileVerified bool
}
-145
View File
@@ -1,145 +0,0 @@
package access
import (
"context"
"encoding/json"
"log/slog"
"sort"
"strings"
"github.com/Silo-Server/silo-server/internal/lang"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ViewerPreferences are the canonical preferences needed while constructing
// an access scope. They are resolved together because this path runs on nearly
// every authenticated request and one candidate read can answer both keys.
type ViewerPreferences struct {
DisabledLibraryIDs []int
PreferredMetadataLanguage string
MetadataLanguageOverrides map[string]string
}
// ResolveViewerPreferences resolves the profile's viewer-scope preferences in
// one canonical store read. The legacy disabled_library_ids account setting is
// consulted only when no canonical row decided that value.
func ResolveViewerPreferences(
ctx context.Context, store userstore.UserStore, profileID string,
) ViewerPreferences {
profileID = strings.TrimSpace(profileID)
if store == nil {
return ViewerPreferences{}
}
if profileID == "" {
return ViewerPreferences{DisabledLibraryIDs: legacyDisabledLibraryIDs(ctx, store)}
}
resolved, ok := resolveCanonicalViewerPreferences(ctx, store, profileID)
if !ok || !resolved.disabledLibraryIDsSet {
resolved.preferences.DisabledLibraryIDs = legacyDisabledLibraryIDs(ctx, store)
}
return resolved.preferences
}
type canonicalViewerPreferences struct {
preferences ViewerPreferences
disabledLibraryIDsSet bool
}
func resolveCanonicalViewerPreferences(
ctx context.Context, store userstore.UserStore, profileID string,
) (canonicalViewerPreferences, bool) {
contract, err := settingscontract.Load()
if err != nil {
slog.WarnContext(ctx, "viewer preference resolution degraded: loading settings contract failed",
"component", "access", "profile_id", profileID, "error", err)
return canonicalViewerPreferences{}, false
}
values, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]string{
settingskeys.UiDisabledLibraryIds,
settingskeys.CatalogMetadataLanguage,
settingskeys.CatalogMetadataLanguageOverrides,
}, nil)
if err != nil {
slog.WarnContext(ctx, "viewer preference resolution degraded: reading setting values failed",
"component", "access", "profile_id", profileID, "error", err)
return canonicalViewerPreferences{}, false
}
var out canonicalViewerPreferences
for _, value := range values {
switch value.Key {
case settingskeys.UiDisabledLibraryIds:
out.disabledLibraryIDsSet = value.Source != settingscontract.ScopeDefault
if out.disabledLibraryIDsSet {
out.preferences.DisabledLibraryIDs = parseLibraryIDList(value.Value)
}
case settingskeys.CatalogMetadataLanguage:
var language string
if json.Unmarshal(value.Value, &language) == nil {
out.preferences.PreferredMetadataLanguage = strings.TrimSpace(language)
}
case settingskeys.CatalogMetadataLanguageOverrides:
out.preferences.MetadataLanguageOverrides = parseMetadataLanguageOverrides(value.Value)
}
}
return out, true
}
// OriginalMetadataLanguage is a private-use BCP 47 tag stored in
// catalog.metadata_language (or as an override target) to mean "resolve this
// media item's original language." Keeping the sentinel a valid language tag
// preserves the existing setting's wire type.
const OriginalMetadataLanguage = "x-silo-original"
// parseMetadataLanguageOverrides normalizes source aliases and target tag
// casing before the map reaches catalog serving. The contract only accepts
// canonical source keys from normal clients, but normalization also makes old
// or manually-authored rows deterministic.
func parseMetadataLanguageOverrides(raw json.RawMessage) map[string]string {
var stored map[string]string
if json.Unmarshal(raw, &stored) != nil || len(stored) == 0 {
return nil
}
keys := make([]string, 0, len(stored))
for source := range stored {
keys = append(keys, source)
}
sort.Strings(keys)
out := make(map[string]string, len(stored))
for _, source := range keys {
canonicalSource := lang.Canonical(source)
if canonicalSource == "" {
continue
}
// When aliases collapse (for example en and eng), the canonical key
// wins because it sorts first and is never overwritten.
if _, exists := out[canonicalSource]; exists {
continue
}
target, ok := settingscontract.NormalizeLanguageTag(stored[source])
if !ok {
continue
}
out[canonicalSource] = target
}
if len(out) == 0 {
return nil
}
return out
}
func legacyDisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
if err != nil || raw == "" {
return nil
}
return parseLibraryIDList(json.RawMessage(raw))
}
+4 -26
View File
@@ -40,28 +40,13 @@ type deleteLibraryExecutor interface {
Execute(ctx context.Context, req DeleteLibraryRequest, progress func(current, total int, message string)) (*DeleteLibraryResult, error)
}
// LibrarySettingsCleaner removes per-user canonical setting values scoped to a
// deleted library. Satisfied by *userstore.SettingValuesCleaner.
type LibrarySettingsCleaner interface {
DeleteForLibrary(ctx context.Context, libraryID int) int64
}
type LibraryDeleteExecutor struct {
folderRepo *catalog.FolderRepository
sectionRepo *sections.Repository
settingsCleaner LibrarySettingsCleaner
folderRepo *catalog.FolderRepository
sectionRepo *sections.Repository
}
func NewLibraryDeleteExecutor(
folderRepo *catalog.FolderRepository,
sectionRepo *sections.Repository,
settingsCleaner LibrarySettingsCleaner,
) *LibraryDeleteExecutor {
return &LibraryDeleteExecutor{
folderRepo: folderRepo,
sectionRepo: sectionRepo,
settingsCleaner: settingsCleaner,
}
func NewLibraryDeleteExecutor(folderRepo *catalog.FolderRepository, sectionRepo *sections.Repository) *LibraryDeleteExecutor {
return &LibraryDeleteExecutor{folderRepo: folderRepo, sectionRepo: sectionRepo}
}
func (e *LibraryDeleteExecutor) Execute(
@@ -97,13 +82,6 @@ func (e *LibraryDeleteExecutor) Execute(
return nil, fmt.Errorf("deleting generated home sections: %w", err)
}
}
if e.settingsCleaner != nil {
// The canonical settings schema declares no FK on library_id, so the
// per-user profile_library values must go with the library or they
// orphan. Best-effort inside the cleaner: the library itself is
// already deleted at this point.
e.settingsCleaner.DeleteForLibrary(ctx, req.LibraryID)
}
if progress != nil {
progress(5, 5, "Library deletion completed")
}
-28
View File
@@ -118,39 +118,11 @@ func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libra
COALESCE(mi.tvdb_id, '') <> ''
OR COALESCE(mi.imdb_id, '') <> ''
)
AND NOT EXISTS (
SELECT 1
FROM stale_media_ids rejected_tmdb
WHERE rejected_tmdb.content_id = mi.content_id
AND LOWER(TRIM(rejected_tmdb.provider)) = 'tmdb'
AND TRIM(rejected_tmdb.provider_id) <> ''
)
)
OR EXISTS (
SELECT 1
FROM stale_media_ids smi
WHERE smi.content_id = mi.content_id
AND LOWER(TRIM(smi.provider)) IN ('tmdb', 'tvdb', 'imdb')
AND TRIM(smi.provider_id) <> ''
AND (
LOWER(TRIM(COALESCE(mi.status, ''))) <> 'matched'
OR (
LOWER(TRIM(smi.provider)) = 'tmdb'
AND TRIM(smi.provider_id) = TRIM(COALESCE(mi.tmdb_id, ''))
)
OR (
LOWER(TRIM(smi.provider)) = 'tvdb'
AND TRIM(smi.provider_id) = TRIM(COALESCE(mi.tvdb_id, ''))
)
OR (
LOWER(TRIM(smi.provider)) = 'imdb'
AND LOWER(TRIM(smi.provider_id)) = LOWER(TRIM(COALESCE(mi.imdb_id, '')))
)
OR LOWER(TRIM(mi.content_id)) =
'movie-' || LOWER(TRIM(smi.provider)) || '-' || LOWER(TRIM(smi.provider_id))
OR LOWER(TRIM(mi.content_id)) =
'series-' || LOWER(TRIM(smi.provider)) || '-' || LOWER(TRIM(smi.provider_id))
)
)
OR (
COALESCE(mi.default_metadata_language, '') <> ''
@@ -91,102 +91,3 @@ func TestQuickRefreshListsLanguageMismatchedItems(t *testing.T) {
t.Errorf("quick refresh must not include complete item whose stamped language matches the library language")
}
}
func TestQuickRefreshIgnoresHistoricalSecondaryStaleIDs(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
historicalID := "movie-tmdb-" + suffix
actionableProviderID := suffix + "1"
actionableID := "movie-tmdb-" + actionableProviderID
rejectedSiblingID := "series-tvdb-" + suffix
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled, metadata_language)
VALUES ('movies', 'Stale Refresh Test', true, 'en')
RETURNING id
`).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{historicalID, actionableID, rejectedSiblingID})
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
for _, row := range []struct {
contentID, tmdbID, staleProvider, staleProviderID string
}{
{historicalID, suffix, "tvdb", "999999"},
{actionableID, actionableProviderID, "tmdb", actionableProviderID},
} {
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, tmdb_id,
default_metadata_language, overview, poster_path, backdrop_path,
last_refreshed, refresh_failures, episode_metadata_incomplete
) VALUES ($1, 'movie', 'Complete Item', 'matched', '{}'::text[], $2,
'en', 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
`, row.contentID, row.tmdbID); err != nil {
t.Fatalf("seed media item %s: %v", row.contentID, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)
`, row.contentID, folderID); err != nil {
t.Fatalf("link media item %s: %v", row.contentID, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO stale_media_ids (content_id, provider, provider_id)
VALUES ($1, $2, $3)
`, row.contentID, row.staleProvider, row.staleProviderID); err != nil {
t.Fatalf("seed stale id for %s: %v", row.contentID, err)
}
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, tvdb_id,
default_metadata_language, overview, poster_path, backdrop_path,
last_refreshed, refresh_failures, episode_metadata_incomplete
) VALUES ($1, 'series', 'Complete TVDB Item', 'matched', '{}'::text[], $2,
'en', 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
`, rejectedSiblingID, suffix); err != nil {
t.Fatalf("seed TVDB-only media item: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)
`, rejectedSiblingID, folderID); err != nil {
t.Fatalf("link TVDB-only media item: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO stale_media_ids (content_id, provider, provider_id)
VALUES ($1, 'tmdb', $2)
`, rejectedSiblingID, suffix+"2"); err != nil {
t.Fatalf("seed rejected TMDB sibling: %v", err)
}
items, err := NewPGLibraryRefreshItemLister(pool).ListLibraryItems(ctx, folderID, LibraryRefreshModeQuick)
if err != nil {
t.Fatalf("ListLibraryItems: %v", err)
}
listed := make(map[string]bool, len(items))
for _, item := range items {
listed[item.ContentID] = true
}
if listed[historicalID] {
t.Fatal("historical secondary stale ID must not keep a complete item in quick refresh")
}
if !listed[actionableID] {
t.Fatal("current canonical stale ID must remain in quick refresh")
}
if listed[rejectedSiblingID] {
t.Fatal("a rejected TMDB sibling must not keep provider-ID enrichment debt active")
}
}
+4 -35
View File
@@ -99,51 +99,20 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
PluginID: inst.PluginID,
CapabilityID: c.ID,
DisplayName: scanSourceDisplayName(inst.PluginID, c),
Description: scanSourceMetadataString(c, "description"),
Descriptor: scanSourceDescriptor(inst.PluginID, c),
})
}
}
return out, nil
}
// scanSourceMetadataString reads a trimmed string out of a capability's
// manifest metadata, returning "" when absent or of the wrong type.
func scanSourceMetadataString(c *plugins.Capability, key string) string {
if c == nil || c.Metadata == nil {
return ""
}
value, ok := c.Metadata[key].(string)
if !ok {
return ""
}
return strings.TrimSpace(value)
}
// scanSourceDescriptor resolves the setup contract for a discovered capability.
// It reads whatever the manifest declares (falling back to host defaults that
// reproduce pre-descriptor behavior), then lets a host-side compatibility
// descriptor fill gaps for first-party plugins that have not yet published one
// of their own.
func scanSourceDescriptor(pluginID string, c *plugins.Capability) autoscan.ScanSourceDescriptor {
var metadata map[string]any
if c != nil {
metadata = c.Metadata
}
declared := autoscan.DescriptorFromMetadata(metadata)
capabilityID := ""
if c != nil {
capabilityID = c.ID
}
return autoscan.ApplyCompatibilityDescriptor(pluginID, capabilityID, declared)
}
// scanSourceDisplayName derives a human-friendly label for a scan_source
// capability: the capability manifest's display_name when present, else the
// plugin id (with the capability id appended when it adds information).
func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
if name := scanSourceMetadataString(c, "display_name"); name != "" {
return name
if c != nil && c.Metadata != nil {
if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
}
switch {
case pluginID != "" && c != nil && c.ID != "":
+366 -58
View File
@@ -37,8 +37,6 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/policy"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingsmigrate"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -1357,6 +1355,10 @@ type adminSettingResponse struct {
RestartRequired bool `json:"restart_required,omitempty"`
}
type adminSettingsListResponse struct {
Settings []adminSettingResponse `json:"settings"`
}
type adminDeviceSettingResponse struct {
UserID int `json:"user_id"`
ProfileID string `json:"profile_id"`
@@ -1411,6 +1413,345 @@ type adminDeviceDetailResponse struct {
Settings []adminDeviceSettingResponse `json:"settings"`
}
// HandleListUserSettings handles GET /admin/users/{id}/settings.
func (h *AdminHandler) HandleListUserSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings")
return
}
resp := adminSettingsListResponse{
Settings: make([]adminSettingResponse, 0, len(entries)),
}
for _, entry := range entries {
if !keyUsesUserScope(entry.Key) {
continue
}
resp.Settings = append(resp.Settings, adminSettingResponse{
Key: entry.Key,
Value: entry.Value,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleGetUserSetting handles GET /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleGetUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
value, err := store.GetSetting(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
return
}
if value == "" {
entries, err := store.ListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
return
}
found := false
for _, entry := range entries {
if entry.Key == key {
found = true
break
}
}
if !found {
writeError(w, http.StatusNotFound, "not_found", "Setting not found")
return
}
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value})
}
// HandleUpdateUserSetting handles PUT /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleUpdateUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
var req updateSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := validateRegisteredSetting(key, req.Value, scopeUser); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.SetSetting(r.Context(), key, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting")
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
}
// HandleDeleteUserSetting handles DELETE /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleDeleteUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.DeleteSetting(r.Context(), key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListUserDeviceSettings handles GET /admin/users/{id}/device-settings.
func (h *AdminHandler) HandleListUserDeviceSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListAllDeviceSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries))
}
// HandleListUserDeviceSettingsByKey handles GET /admin/users/{id}/device-settings/{key}.
func (h *AdminHandler) HandleListUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListDeviceSettings(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries))
}
// HandleUpdateUserDeviceSetting handles PUT /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}.
func (h *AdminHandler) HandleUpdateUserDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
key := strings.TrimSpace(chi.URLParam(r, "key"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
var req updateSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := validateRegisteredSetting(key, req.Value, scopeDevice); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
existing, err := store.GetDeviceSetting(r.Context(), profileID, deviceID, key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device setting")
return
}
entry := userstore.DeviceSettingEntry{
ProfileID: profileID,
DeviceID: deviceID,
Key: key,
Value: req.Value,
}
if existing != nil {
entry.DeviceName = existing.DeviceName
entry.DevicePlatform = existing.DevicePlatform
} else if registered, err := registeredDeviceForProfile(r.Context(), store, profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
return
} else if registered != nil {
entry.DeviceName = registered.DeviceName
entry.DevicePlatform = registered.DevicePlatform
}
if err := store.SetDeviceSetting(r.Context(), entry); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device setting")
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
}
// HandleDeleteUserDeviceSetting handles DELETE /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}.
func (h *AdminHandler) HandleDeleteUserDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
key := strings.TrimSpace(chi.URLParam(r, "key"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
if err := store.DeleteDeviceSetting(r.Context(), profileID, deviceID, key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDeleteAllUserDeviceSettings handles DELETE /admin/users/{id}/profiles/{profile_id}/devices/{device_id}/settings.
func (h *AdminHandler) HandleDeleteAllUserDeviceSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
if err := store.DeleteAllDeviceSettings(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDeleteUserDeviceSettingsByKey handles DELETE /admin/users/{id}/device-settings/{key}.
func (h *AdminHandler) HandleDeleteUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.DeleteDeviceSettingsByKey(r.Context(), key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListDevices handles GET /admin/devices.
func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
if h.userRepo == nil || h.storeProv == nil {
@@ -1438,10 +1779,6 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
if err != nil {
return fmt.Errorf("list device settings: %w", err)
}
canonicalValues, err := store.ListAllSettingValues(gctx)
if err != nil {
return fmt.Errorf("list canonical setting values: %w", err)
}
devices, err := listRegisteredDevices(gctx, store)
if err != nil {
return fmt.Errorf("list devices: %w", err)
@@ -1459,7 +1796,6 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
user.Username,
user.Email,
entries,
canonicalValues,
devices,
profileNames,
)
@@ -1526,11 +1862,6 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
return
}
canonicalValues, err := store.ListAllSettingValues(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
return
}
registeredDevices, err := listRegisteredDevices(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
@@ -1554,18 +1885,11 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
deviceRegistrations = append(deviceRegistrations, entry)
}
}
deviceCanonicalValues := make([]userstore.SettingValue, 0)
for _, value := range canonicalValues {
if value.Scope == settingscontract.ScopeProfileDevice && value.DeviceID == deviceID {
deviceCanonicalValues = append(deviceCanonicalValues, value)
}
}
summaries := buildAdminDeviceSummaries(
user.ID,
user.Username,
user.Email,
deviceEntries,
deviceCanonicalValues,
deviceRegistrations,
profileNames,
)
@@ -1598,6 +1922,25 @@ func listRegisteredDevices(ctx context.Context, store userstore.UserStore) ([]us
return registry.ListDevices(ctx)
}
func registeredDeviceForProfile(
ctx context.Context,
store userstore.UserStore,
profileID string,
deviceID string,
) (*userstore.DeviceEntry, error) {
devices, err := listRegisteredDevices(ctx, store)
if err != nil {
return nil, err
}
for _, device := range devices {
if device.ProfileID == profileID && device.DeviceID == deviceID {
matched := device
return &matched, nil
}
}
return nil, nil
}
func buildAdminDeviceSettingsResponse(userID int, profileNames map[string]string, entries []userstore.DeviceSettingEntry) adminDeviceSettingsListResponse {
resp := adminDeviceSettingsListResponse{
Settings: make([]adminDeviceSettingResponse, 0, len(entries)),
@@ -1623,7 +1966,6 @@ func buildAdminDeviceSummaries(
username string,
email string,
entries []userstore.DeviceSettingEntry,
canonicalValues []userstore.SettingValue,
registeredDevices []userstore.DeviceEntry,
profileNames map[string]string,
) []adminDeviceSummaryResponse {
@@ -1725,36 +2067,12 @@ func buildAdminDeviceSummaries(
if current == nil {
continue
}
key := canonicalAdminDeviceSettingKey(entry.Key)
if profileID != "" && key != "" {
current.keys[profileID+":"+key] = struct{}{}
if profileID != "" && entry.Key != "" {
current.keys[profileID+":"+entry.Key] = struct{}{}
}
profile := ensureProfile(current, profileID, entry.UpdatedAt)
if profile != nil && key != "" {
profile.keys[key] = struct{}{}
}
}
// Canonical profile_device rows are the authoritative overrides after the
// settings cutover. Merge them by (profile,key) with the still-mounted
// legacy rows so a mirrored value counts once while a canonical-only write
// remains visible to fleet management.
for _, value := range canonicalValues {
if value.Scope != settingscontract.ScopeProfileDevice {
continue
}
deviceID := strings.TrimSpace(value.DeviceID)
profileID := strings.TrimSpace(value.ProfileID)
current := ensureDevice(deviceID, "", "", value.UpdatedAt)
if current == nil {
continue
}
if profileID != "" && value.Key != "" {
current.keys[profileID+":"+value.Key] = struct{}{}
}
profile := ensureProfile(current, profileID, value.UpdatedAt)
if profile != nil && value.Key != "" {
profile.keys[value.Key] = struct{}{}
if profile != nil && entry.Key != "" {
profile.keys[entry.Key] = struct{}{}
}
}
@@ -1787,13 +2105,6 @@ func buildAdminDeviceSummaries(
return devices
}
// canonicalAdminDeviceSettingKey uses the migration's rename table so fleet
// counts describe logical overrides and every legacy/canonical pair counts
// once, including pre-cutover appearance rows left in the legacy table.
func canonicalAdminDeviceSettingKey(key string) string {
return settingsmigrate.CanonicalKey(strings.TrimSpace(key))
}
func listProfileNamesByID(ctx context.Context, store userstore.UserStore) (map[string]string, error) {
profiles, err := store.ListProfiles(ctx)
if err != nil {
@@ -2275,10 +2586,7 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
return
}
req.Value = normalized
case "ai.base_url", "ai.chat_model", "ai.asr_model":
req.Value = strings.TrimSpace(req.Value)
case "ai.asr_base_url":
req.Value = strings.TrimSpace(req.Value)
if llm.IsChatOnlyGateway(req.Value) {
writeError(w, http.StatusBadRequest, "bad_request",
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
-211
View File
@@ -1,211 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/invitations"
"github.com/Silo-Server/silo-server/internal/models"
)
// AdminInvitationHandler handles admin endpoints for emailed invitations.
type AdminInvitationHandler struct {
service *invitations.Service
}
// NewAdminInvitationHandler creates a new AdminInvitationHandler.
func NewAdminInvitationHandler(service *invitations.Service) *AdminInvitationHandler {
return &AdminInvitationHandler{service: service}
}
// --- Request/Response types ---
type createInvitationRequest struct {
Email string `json:"email"`
Role string `json:"role"`
AccessGroupID *int64 `json:"access_group_id"`
LibraryIDs []int `json:"library_ids"`
CreateProfile *bool `json:"create_profile"`
ShowTour *bool `json:"show_tour"`
Note string `json:"note"`
}
type invitationResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
AccessGroupID *int64 `json:"access_group_id,omitempty"`
LibraryIDs []int `json:"library_ids,omitempty"`
CreateProfile bool `json:"create_profile"`
ShowTour bool `json:"show_tour"`
Note string `json:"note,omitempty"`
InvitedBy int64 `json:"invited_by"`
InvitedByName string `json:"invited_by_name,omitempty"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt *string `json:"accepted_at,omitempty"`
AcceptedUser *int64 `json:"accepted_user_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type sendInvitationResponse struct {
Invitation invitationResponse `json:"invitation"`
EmailSent bool `json:"email_sent"`
// ClaimURL embeds the single-use token, so this response is the only
// chance to read it — the server keeps just the hash. Returned even when
// the email sent, so the admin can also deliver the link directly.
ClaimURL string `json:"claim_url,omitempty"`
}
func toInvitationResponse(inv *models.Invitation, now time.Time) invitationResponse {
resp := invitationResponse{
ID: inv.ID,
Email: inv.Email,
Role: inv.Role,
AccessGroupID: inv.AccessGroupID,
LibraryIDs: inv.LibraryIDs,
CreateProfile: inv.CreateProfile,
ShowTour: inv.ShowTour,
Note: inv.Note,
InvitedBy: inv.InvitedBy,
InvitedByName: inv.InvitedByName,
Status: inv.Status(now),
ExpiresAt: inv.ExpiresAt,
AcceptedUser: inv.AcceptedUserID,
CreatedAt: inv.CreatedAt,
}
if inv.AcceptedAt != nil {
accepted := inv.AcceptedAt.Format(time.RFC3339)
resp.AcceptedAt = &accepted
}
return resp
}
// HandleListInvitations handles GET /admin/invitations.
func (h *AdminInvitationHandler) HandleListInvitations(w http.ResponseWriter, r *http.Request) {
list, err := h.service.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list invitations")
return
}
now := time.Now()
resp := make([]invitationResponse, 0, len(list))
for _, inv := range list {
resp = append(resp, toInvitationResponse(inv, now))
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreateInvitation handles POST /admin/invitations.
func (h *AdminInvitationHandler) HandleCreateInvitation(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
var req createInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
createProfile := true
if req.CreateProfile != nil {
createProfile = *req.CreateProfile
}
showTour := true
if req.ShowTour != nil {
showTour = *req.ShowTour
}
result, err := h.service.Send(r.Context(), invitations.SendInput{
Email: req.Email,
Role: req.Role,
AccessGroupID: req.AccessGroupID,
LibraryIDs: req.LibraryIDs,
CreateProfile: createProfile,
ShowTour: showTour,
Note: req.Note,
InvitedBy: int64(claims.UserID),
})
if err != nil {
writeInvitationSendError(w, err)
return
}
writeJSON(w, http.StatusCreated, buildSendResponse(result))
}
// HandleResendInvitation handles POST /admin/invitations/{id}/resend.
func (h *AdminInvitationHandler) HandleResendInvitation(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid invitation ID")
return
}
result, err := h.service.Resend(r.Context(), id, int64(claims.UserID))
if err != nil {
if errors.Is(err, invitations.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Invitation not found")
return
}
writeInvitationSendError(w, err)
return
}
writeJSON(w, http.StatusOK, buildSendResponse(result))
}
// HandleRevokeInvitation handles DELETE /admin/invitations/{id}.
func (h *AdminInvitationHandler) HandleRevokeInvitation(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid invitation ID")
return
}
if err := h.service.Revoke(r.Context(), id); err != nil {
if errors.Is(err, invitations.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Invitation not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke invitation")
return
}
w.WriteHeader(http.StatusNoContent)
}
func buildSendResponse(result *invitations.SendResult) sendInvitationResponse {
resp := sendInvitationResponse{
Invitation: toInvitationResponse(result.Invitation, time.Now()),
EmailSent: result.EmailSent,
ClaimURL: result.ClaimURL,
}
return resp
}
func writeInvitationSendError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invitations.ErrInvalidEmail):
writeError(w, http.StatusBadRequest, "invalid_email", "Invalid email address")
case errors.Is(err, invitations.ErrEmailTaken):
writeError(w, http.StatusConflict, "email_taken", "An account with this email already exists")
case errors.Is(err, invitations.ErrRoleNotAllowed):
writeError(w, http.StatusForbidden, "role_not_allowed", "You may not grant this role")
case errors.Is(err, invitations.ErrNoLinkBase):
writeError(w, http.StatusConflict, "no_link_base",
"Configure notifications.email.external_url (or a server public URL) so invitation links can be built")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send invitation")
}
}
+1 -180
View File
@@ -3,19 +3,16 @@ package handlers
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/redis/go-redis/v9"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
@@ -54,11 +51,6 @@ type mdblistSettingsCheckClient interface {
Check(ctx context.Context) error
}
type aiSettingsCheckClient interface {
Chat(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error)
Transcribe(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
}
type redisSettingsCheckAdapter struct {
client *redis.Client
}
@@ -96,10 +88,6 @@ var newAdminMDBListSettingsCheckClient = func(apiKey string) mdblistSettingsChec
return mdblist.NewClient(apiKey, nil)
}
var newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
return llm.NewClient(cfg)
}
func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *http.Request) {
if h.SettingsRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
@@ -121,7 +109,7 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
req.Values = map[string]string{}
}
effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), kind, req)
effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), req)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings")
return
@@ -143,10 +131,6 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
response = checkRedisConnection(r.Context(), cfg)
case "recommendations_embedding":
response = checkRecommendationsEmbeddingConnection(r.Context(), cfg)
case "ai_chat":
response = checkAIChatConnection(r.Context(), cfg)
case "ai_transcription":
response = checkAITranscriptionConnection(r.Context(), cfg)
case "meilisearch":
response = checkMeilisearchConnection(r.Context(), effectiveSettings)
case "mdblist":
@@ -171,105 +155,6 @@ func checkMDBListConnection(ctx context.Context, cfg *config.Config) connectionC
return connectionCheckResponse{Success: true, Message: "MDBList API key verified."}
}
func aiClientConfig(cfg *config.Config) llm.Config {
return llm.Config{
BaseURL: strings.TrimSpace(cfg.AI.BaseURL),
APIKey: cfg.AI.APIKey,
ChatModel: strings.TrimSpace(cfg.AI.ChatModel),
ASRBaseURL: strings.TrimSpace(cfg.AI.ASRBaseURL),
ASRAPIKey: cfg.AI.ASRAPIKey,
ASRModel: strings.TrimSpace(cfg.AI.ASRModel),
}
}
func checkAIChatConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
return connectionCheckResponse{Success: false, Message: "Text AI base URL is required."}
}
if strings.TrimSpace(cfg.AI.ChatModel) == "" {
return connectionCheckResponse{Success: false, Message: "Chat model is required."}
}
client := newAdminAISettingsCheckClient(aiClientConfig(cfg))
checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if _, err := client.Chat(checkCtx, []llm.Message{
{Role: "system", Content: "Return a JSON object with status set to ok."},
{Role: "user", Content: "Check this Silo text translation connection."},
}, true); err != nil {
return connectionCheckResponse{
Success: false,
Message: fmt.Sprintf("Text AI connection check failed: %v", err),
}
}
return connectionCheckResponse{Success: true, Message: "Text AI connection successful."}
}
func checkAITranscriptionConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
effectiveBaseURL := strings.TrimSpace(cfg.AI.ASRBaseURL)
if effectiveBaseURL == "" {
effectiveBaseURL = strings.TrimSpace(cfg.AI.BaseURL)
}
if effectiveBaseURL == "" {
return connectionCheckResponse{
Success: false,
Message: "Speech-to-text base URL is required.",
}
}
if strings.TrimSpace(cfg.AI.ASRModel) == "" {
return connectionCheckResponse{
Success: false,
Message: "Transcription model is required.",
}
}
client := newAdminAISettingsCheckClient(aiClientConfig(cfg))
checkCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
defer cancel()
if _, err := client.Transcribe(checkCtx, llm.TranscribeRequest{
Filename: "silo-connection-check.wav",
Audio: silenceWAV(),
Timeout: 30 * time.Second,
}); err != nil {
return connectionCheckResponse{
Success: false,
Message: fmt.Sprintf("Speech-to-text connection check failed: %v", err),
}
}
return connectionCheckResponse{
Success: true,
Message: "Speech-to-text connection successful.",
}
}
// silenceWAV returns 250 ms of 16 kHz mono PCM. It is long enough for
// transcription providers to parse while keeping connection checks cheap.
func silenceWAV() []byte {
const (
sampleRate = 16_000
bitsPerSample = 16
sampleCount = sampleRate / 4
dataSize = sampleCount * bitsPerSample / 8
)
wav := make([]byte, 44+dataSize)
copy(wav[0:4], "RIFF")
binary.LittleEndian.PutUint32(wav[4:8], uint32(36+dataSize))
copy(wav[8:12], "WAVE")
copy(wav[12:16], "fmt ")
binary.LittleEndian.PutUint32(wav[16:20], 16)
binary.LittleEndian.PutUint16(wav[20:22], 1)
binary.LittleEndian.PutUint16(wav[22:24], 1)
binary.LittleEndian.PutUint32(wav[24:28], sampleRate)
binary.LittleEndian.PutUint32(wav[28:32], sampleRate*bitsPerSample/8)
binary.LittleEndian.PutUint16(wav[32:34], bitsPerSample/8)
binary.LittleEndian.PutUint16(wav[34:36], bitsPerSample)
copy(wav[36:40], "data")
binary.LittleEndian.PutUint32(wav[40:44], dataSize)
return wav
}
func checkMeilisearchConnection(ctx context.Context, settings map[string]string) connectionCheckResponse {
searchSettings, err := catalog.CatalogSearchSettingsFromMap(settings)
if err != nil {
@@ -288,7 +173,6 @@ func checkMeilisearchConnection(ctx context.Context, settings map[string]string)
func (h *AdminHandler) effectiveSettingsForConnectionCheck(
ctx context.Context,
kind string,
req adminSettingsConnectionCheckRequest,
) (map[string]string, error) {
settings, err := h.SettingsRepo.GetAll(ctx)
@@ -306,76 +190,13 @@ func (h *AdminHandler) effectiveSettingsForConnectionCheck(
}
merged[key] = value
}
var storedAIConfig *config.Config
if kind == "ai_chat" || kind == "ai_transcription" {
storedAIConfig, err = config.LoadFromDB(merged)
if err != nil {
return nil, err
}
}
for _, key := range req.DirtyKeys {
merged[key] = req.Values[key]
}
if storedAIConfig != nil {
draftAIConfig, loadErr := config.LoadFromDB(merged)
if loadErr != nil {
return nil, loadErr
}
protectAIConnectionCheckSecrets(kind, req, storedAIConfig, draftAIConfig, merged)
}
return merged, nil
}
func protectAIConnectionCheckSecrets(
kind string,
req adminSettingsConnectionCheckRequest,
storedCfg *config.Config,
draftCfg *config.Config,
settings map[string]string,
) {
storedEndpoint := storedCfg.AI.BaseURL
draftEndpoint := draftCfg.AI.BaseURL
if kind == "ai_transcription" {
if strings.TrimSpace(storedCfg.AI.ASRBaseURL) != "" {
storedEndpoint = storedCfg.AI.ASRBaseURL
}
if strings.TrimSpace(draftCfg.AI.ASRBaseURL) != "" {
draftEndpoint = draftCfg.AI.ASRBaseURL
}
}
if endpointAuthority(storedEndpoint) == endpointAuthority(draftEndpoint) {
return
}
if kind == "ai_transcription" && !hasExplicitDraftSecret(req, "ai.asr_api_key") {
settings["ai.asr_api_key"] = ""
}
if !hasExplicitDraftSecret(req, "ai.api_key") {
settings["ai.api_key"] = ""
settings["subtitle_ai.api_key"] = ""
}
}
func hasExplicitDraftSecret(req adminSettingsConnectionCheckRequest, key string) bool {
for _, dirtyKey := range req.DirtyKeys {
if dirtyKey == key {
return strings.TrimSpace(req.Values[key]) != ""
}
}
return false
}
func endpointAuthority(rawURL string) string {
trimmed := strings.TrimSpace(rawURL)
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return strings.ToLower(trimmed)
}
return strings.ToLower(parsed.Scheme + "://" + parsed.Host)
}
func checkS3PublicConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
if strings.TrimSpace(cfg.S3.Public.Endpoint) == "" {
return connectionCheckResponse{Success: false, Message: "S3 endpoint is required."}
@@ -14,7 +14,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/diagnostics"
@@ -887,239 +886,6 @@ func (f *fakeEmbeddingsSettingsCheckClient) Embed(
return [][]float32{{0.1, 0.2}}, nil
}
type fakeAISettingsCheckClient struct {
chat func(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error)
transcribe func(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
}
func (f *fakeAISettingsCheckClient) Chat(
ctx context.Context,
messages []llm.Message,
jsonObject bool,
) (string, error) {
if f.chat != nil {
return f.chat(ctx, messages, jsonObject)
}
return `{"status":"ok"}`, nil
}
func (f *fakeAISettingsCheckClient) Transcribe(
ctx context.Context,
req llm.TranscribeRequest,
) (*llm.Transcription, error) {
if f.transcribe != nil {
return f.transcribe(ctx, req)
}
return &llm.Transcription{}, nil
}
func TestHandleCheckSettingsConnectionAIChatDoesNotSendStoredKeyToDraftEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
var chatCalled bool
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{
chat: func(_ context.Context, _ []llm.Message, jsonObject bool) (string, error) {
chatCalled = true
if !jsonObject {
t.Fatal("chat connection check did not request a JSON response")
}
return `{"status":"ok"}`, nil
},
}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://persisted.example.test",
"ai.chat_model": "persisted-model",
"ai.api_key": "persisted-secret",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_chat",
map[string]any{
"values": map[string]string{
"ai.base_url": "https://draft.example.test",
"ai.chat_model": "draft-model",
"ai.api_key": "",
},
"dirty_keys": []string{"ai.base_url", "ai.chat_model"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if !response.Success || !chatCalled {
t.Fatalf("response = %+v, chatCalled = %v", response, chatCalled)
}
if captured.BaseURL != "https://draft.example.test" || captured.ChatModel != "draft-model" {
t.Fatalf("captured config = %+v, want draft endpoint and model", captured)
}
if captured.APIKey != "" {
t.Fatalf("captured API key = %q, want no stored secret for a changed endpoint", captured.APIKey)
}
}
func TestHandleCheckSettingsConnectionAIChatReusesStoredKeyForSameEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://persisted.example.test/v1",
"ai.chat_model": "persisted-model",
"ai.api_key": "persisted-secret",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_chat",
map[string]any{
"values": map[string]string{
"ai.base_url": "https://persisted.example.test/other-path",
"ai.chat_model": "draft-model",
},
"dirty_keys": []string{"ai.base_url", "ai.chat_model"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if captured.APIKey != "persisted-secret" {
t.Fatalf("captured API key = %q, want stored secret for unchanged authority", captured.APIKey)
}
}
func TestHandleCheckSettingsConnectionAITranscriptionUsesDedicatedASR(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
var request llm.TranscribeRequest
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{
transcribe: func(_ context.Context, req llm.TranscribeRequest) (*llm.Transcription, error) {
request = req
return &llm.Transcription{}, nil
},
}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://chat.example.test",
"ai.api_key": "chat-secret",
"ai.chat_model": "chat-model",
"ai.asr_base_url": "https://whisper.example.test",
"ai.asr_api_key": "asr-secret",
"ai.asr_model": "whisper-model",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_transcription",
map[string]any{"values": map[string]string{}, "dirty_keys": []string{}},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if !response.Success {
t.Fatalf("response.Success = false, message = %q", response.Message)
}
if captured.ASRBaseURL != "https://whisper.example.test" ||
captured.ASRAPIKey != "asr-secret" ||
captured.ASRModel != "whisper-model" {
t.Fatalf("captured ASR config = %+v", captured)
}
if len(request.Audio) == 0 || request.Filename == "" || request.Timeout <= 0 {
t.Fatalf("transcription probe request = %+v, want bounded WAV probe", request)
}
}
func TestHandleCheckSettingsConnectionAITranscriptionDoesNotSendStoredKeysToDraftEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://chat.example.test",
"ai.api_key": "chat-secret",
"ai.chat_model": "chat-model",
"ai.asr_base_url": "https://stored-whisper.example.test",
"ai.asr_api_key": "asr-secret",
"ai.asr_model": "whisper-model",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_transcription",
map[string]any{
"values": map[string]string{
"ai.asr_base_url": "https://draft-whisper.example.test",
},
"dirty_keys": []string{"ai.asr_base_url"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if captured.ASRAPIKey != "" || captured.APIKey != "" {
t.Fatalf("captured config = %+v, want no stored ASR or inherited chat secret", captured)
}
}
func TestHandleCheckSettingsConnectionS3UsesPersistedSensitiveValues(t *testing.T) {
originalFactory := newAdminS3SettingsCheckClient
t.Cleanup(func() {
@@ -1472,44 +1238,6 @@ func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
}
}
func TestAdminUpdateSettingNormalizesAIEndpointAndModelValues(t *testing.T) {
for _, tc := range []struct {
key string
value string
want string
}{
{key: "ai.base_url", value: " https://text.example.test/v1 ", want: "https://text.example.test/v1"},
{key: "ai.chat_model", value: " chat-model ", want: "chat-model"},
{key: "ai.asr_base_url", value: " https://speech.example.test ", want: "https://speech.example.test"},
{key: "ai.asr_model", value: " whisper-model ", want: "whisper-model"},
} {
t.Run(tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
body, err := json.Marshal(updateSettingRequest{Value: tc.value})
if err != nil {
t.Fatalf("Marshal() returned error: %v", err)
}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
bytes.NewReader(body),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values[tc.key] != tc.want {
t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.want)
}
})
}
}
func TestAdminUpdatePolicyEditorEnabledValidation(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
+4 -27
View File
@@ -7,18 +7,12 @@ import (
"github.com/go-chi/chi/v5"
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/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// AudioPrefHandler handles per-series audio preference endpoints. Concrete
// track identity remains in the specialized table; the language is mirrored
// to the canonical profile_series row consumed by playback.
// AudioPrefHandler handles per-series audio preference endpoints.
type AudioPrefHandler struct {
storeProvider userstore.UserStoreProvider
EventsHub *evt.Hub
}
// NewAudioPrefHandler creates a new AudioPrefHandler.
@@ -106,20 +100,9 @@ func (h *AudioPrefHandler) HandleSetAudioPref(w http.ResponseWriter, r *http.Req
AudioLanguage: req.AudioLanguage,
TrackSignature: req.TrackSignature,
}
language := req.AudioLanguage
sync, err := appendStringSync(nil, settingskeys.PlaybackAudioLanguage, &language)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if err := applyLegacyPreferenceSettingsSync(r.Context(), store, h.EventsHub, userID,
userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileSeries, ProfileID: profileID, SeriesID: seriesID,
}, sync, func(tx userstore.PreferenceSettingsWriter) error {
return tx.SetAudioPreference(r.Context(), pref)
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store audio preference")
if err := store.SetAudioPreference(r.Context(), pref); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set audio preference")
return
}
@@ -143,13 +126,7 @@ func (h *AudioPrefHandler) HandleDeleteAudioPref(w http.ResponseWriter, r *http.
return
}
if err := applyLegacyPreferenceSettingsSync(r.Context(), store, h.EventsHub, userID,
userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileSeries, ProfileID: profileID, SeriesID: seriesID,
}, []profileSettingSync{{key: settingskeys.PlaybackAudioLanguage}},
func(tx userstore.PreferenceSettingsWriter) error {
return tx.DeleteAudioPreference(r.Context(), profileID, seriesID)
}); err != nil {
if err := store.DeleteAudioPreference(r.Context(), profileID, seriesID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete audio preference")
return
}
-81
View File
@@ -1,81 +0,0 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func routeAudioPref(
t *testing.T,
h *AudioPrefHandler,
method string,
seriesID string,
body []byte,
) *httptest.ResponseRecorder {
t.Helper()
req := valuesRequest(method, "/audio-prefs/"+seriesID, body)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("series_id", seriesID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
if method == http.MethodPut {
h.HandleSetAudioPref(rec, req)
} else {
h.HandleDeleteAudioPref(rec, req)
}
return rec
}
func TestLegacyAudioPreferenceKeepsTrackIdentityAndSyncsCanonicalLanguage(t *testing.T) {
_, store := newValuesTestHandler(t)
handler := NewAudioPrefHandler(testUserStoreProvider{store: store})
rec := routeAudioPref(t, handler, http.MethodPut, "series-1", []byte(`{
"audio_track_index":2,
"audio_language":"ja",
"track_signature":{"language":"ja","codec":"aac","channels":2}
}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
legacy, err := store.GetAudioPreference(context.Background(), "profile-1", "series-1")
if err != nil || legacy == nil {
t.Fatalf("reading specialized preference: value=%+v err=%v", legacy, err)
}
if legacy.AudioTrackIndex != 2 || legacy.TrackSignature == nil {
t.Errorf("specialized track identity was lost: %+v", legacy)
}
canonicalID := userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage, Scope: settingscontract.ScopeProfileSeries,
ProfileID: "profile-1", SeriesID: "series-1",
}
canonical, err := store.GetSettingValue(context.Background(), canonicalID)
if err != nil || canonical == nil || string(canonical.Value) != `"ja"` {
t.Fatalf("canonical language = %+v err=%v, want ja", canonical, err)
}
// Empty is the legacy spelling of unset. The track identity remains
// specialized, while the canonical language inherits from the next scope.
rec = routeAudioPref(t, handler, http.MethodPut, "series-1",
[]byte(`{"audio_track_index":2,"audio_language":""}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("clearing PUT = %d: %s", rec.Code, rec.Body.String())
}
canonical, err = store.GetSettingValue(context.Background(), canonicalID)
if err != nil || canonical != nil {
t.Fatalf("empty language left canonical value=%+v err=%v", canonical, err)
}
rec = routeAudioPref(t, handler, http.MethodDelete, "series-1", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
}
+1 -3
View File
@@ -10,7 +10,6 @@ import (
"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/clientip"
"github.com/Silo-Server/silo-server/internal/models"
@@ -345,8 +344,7 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
}
const ttl = 5 * time.Minute
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, profileID, ttl)
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
return
@@ -1,83 +0,0 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
func TestPluginLaunchCookieCarriesValidatedProfile(t *testing.T) {
jwt := auth.NewJWTService("plugin-launch-test-secret", time.Minute, time.Hour)
accessToken, err := jwt.GenerateAccessToken(7, "user", "session-1")
if err != nil {
t.Fatalf("GenerateAccessToken: %v", err)
}
handler := NewAuthHandler(nil, jwt, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/plugin-launch", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req = req.WithContext(apimw.SetProfileID(req.Context(), "profile-1"))
rec := httptest.NewRecorder()
handler.HandlePluginLaunch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
response := rec.Result()
defer func() { _ = response.Body.Close() }()
var pluginCookie *http.Cookie
for _, cookie := range response.Cookies() {
if cookie.Name == auth.PluginAccessCookieName {
pluginCookie = cookie
break
}
}
if pluginCookie == nil {
t.Fatal("plugin access cookie was not set")
}
claims, err := jwt.ValidateToken(pluginCookie.Value)
if err != nil {
t.Fatalf("validating plugin cookie: %v", err)
}
if claims.ProfileID != "profile-1" || claims.TokenType != auth.TokenTypePluginAccess {
t.Fatalf("plugin claims = %#v", claims)
}
}
func TestPluginLaunchPreservesProfileOptionalCompatibility(t *testing.T) {
jwt := auth.NewJWTService("plugin-launch-test-secret", time.Minute, time.Hour)
accessToken, err := jwt.GenerateAccessToken(7, "user", "session-1")
if err != nil {
t.Fatalf("GenerateAccessToken: %v", err)
}
handler := NewAuthHandler(nil, jwt, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/plugin-launch", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
rec := httptest.NewRecorder()
handler.HandlePluginLaunch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
}
response := rec.Result()
defer func() { _ = response.Body.Close() }()
var pluginCookie *http.Cookie
for _, cookie := range response.Cookies() {
if cookie.Name == auth.PluginAccessCookieName {
pluginCookie = cookie
break
}
}
if pluginCookie == nil {
t.Fatal("plugin access cookie was not set")
}
claims, err := jwt.ValidateToken(pluginCookie.Value)
if err != nil {
t.Fatalf("validating plugin cookie: %v", err)
}
if claims.ProfileID != "" || claims.TokenType != auth.TokenTypePluginAccess {
t.Fatalf("plugin claims = %#v", claims)
}
}
+3 -10
View File
@@ -454,15 +454,10 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
// --- Available scan-source plugins (Add-source picker) ---
// autoscanScanSourcePluginResponse describes one creatable scan source. The
// descriptor is additive: clients that predate it keep reading the same three
// identity fields.
type autoscanScanSourcePluginResponse struct {
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
Descriptor autoscan.ScanSourceDescriptor `json:"descriptor"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
DisplayName string `json:"display_name"`
}
// HandleListAvailableScanSources returns every installed scan_source capability
@@ -479,8 +474,6 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
PluginID: a.PluginID,
CapabilityID: a.CapabilityID,
DisplayName: a.DisplayName,
Description: a.Description,
Descriptor: a.Descriptor,
})
}
writeJSON(w, http.StatusOK, struct {
+1 -6
View File
@@ -228,11 +228,6 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
}
if len(seasons) > 0 {
if h.items.detailSvc != nil {
if localized, locErr := h.items.detailSvc.LocalizeSeasonModels(r.Context(), seasons, filter); locErr == nil && len(localized) == len(seasons) {
seasons = localized
}
}
episodesBySeason, err := h.items.episodeRepo.ListBySeriesGroupedBySeason(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
@@ -250,7 +245,7 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
if hasProgressMap {
userData = catalog.EpisodeRollupUserData(episodes, progressMap)
}
sr := h.items.seasonResponseFromEpisodes(r, s, episodes, userData)
sr := h.items.toSeasonResponseFromEpisodes(r, id, s, episodes, userData)
resp = append(resp, sr)
}
@@ -1,103 +0,0 @@
package handlers
import (
"context"
"net/http"
"github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/jellycompat"
)
// compatConnectInfoKeys are the only settings this endpoint consults. They are
// read individually rather than via GetAll so an authenticated page view never
// loads or decrypts unrelated secrets, and so a decryption failure elsewhere in
// the settings table cannot mask a valid compat override.
var compatConnectInfoKeys = []string{
"jellyfin_compat.enabled",
"jellyfin_compat.public_url",
"jellyfin_compat.server_name",
}
// CompatConnectInfoHandler serves the account-facing view of the compatibility
// listeners: where to point a third-party client, and whether it is even on.
//
// The admin status endpoint covers the same ground for operators, but it also
// reports install paths and version provenance, so it stays admin-only. This
// handler exposes only the address a client would learn by connecting anyway.
type CompatConnectInfoHandler struct {
Config *config.Config
SettingsRepo ServerSettingsStore
Users UserRepository
}
func NewCompatConnectInfoHandler(
cfg *config.Config,
settings ServerSettingsStore,
users UserRepository,
) *CompatConnectInfoHandler {
return &CompatConnectInfoHandler{Config: cfg, SettingsRepo: settings, Users: users}
}
type compatConnectAccountInfo struct {
// PasswordLoginAvailable reports whether this account can authenticate
// with a password at all. Compat login is hardwired to the local provider,
// so SSO/plugin-provisioned accounts cannot sign in to a Jellyfin client
// no matter what they type.
PasswordLoginAvailable bool `json:"password_login_available"`
}
type compatConnectInfoResponse struct {
Jellyfin jellycompat.ConnectInfo `json:"jellyfin"`
Account compatConnectAccountInfo `json:"account"`
}
// HandleGetConnectInfo handles GET /compat/connect-info.
func (h *CompatConnectInfoHandler) HandleGetConnectInfo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
writeJSON(w, http.StatusOK, compatConnectInfoResponse{
Jellyfin: jellycompat.ConnectInfoForConfig(h.Config, h.compatSettings(ctx)),
Account: compatConnectAccountInfo{
PasswordLoginAvailable: h.passwordLoginAvailable(ctx),
},
})
}
// compatSettings loads the stored overrides this endpoint cares about. A
// missing store or a failed read is not fatal: the bootstrap config alone still
// describes a usable listener, so fall back to it rather than failing a purely
// informational read.
func (h *CompatConnectInfoHandler) compatSettings(ctx context.Context) map[string]string {
if h.SettingsRepo == nil {
return nil
}
settings := make(map[string]string, len(compatConnectInfoKeys))
for _, key := range compatConnectInfoKeys {
value, err := h.SettingsRepo.Get(ctx, key)
if err != nil {
continue
}
settings[key] = value
}
return settings
}
// passwordLoginAvailable reports whether the requesting account can use the
// password flow. It defaults to true when the user cannot be resolved: the page
// then shows its normal instructions rather than telling someone their working
// login is unsupported because of a lookup blip.
func (h *CompatConnectInfoHandler) passwordLoginAvailable(ctx context.Context) bool {
if h.Users == nil {
return true
}
userID := middleware.GetUserID(ctx)
if userID == 0 {
return true
}
user, err := h.Users.GetByID(ctx, userID)
if err != nil || user == nil {
return true
}
return user.LocalPasswordLoginEnabled
}
-326
View File
@@ -1,326 +0,0 @@
package handlers
import (
"log/slog"
"net/http"
"sort"
"strings"
"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/userstore"
)
// DeviceHandler serves a viewer's own device registry: the devices they watch
// on, and the settings those devices carry.
//
// This is the self-service twin of the admin device routes. The important
// difference is scoping. The store's ListDevices is account-wide by
// construction — "WHERE user_id" in Postgres, and no WHERE at all in the
// per-user SQLite backend, which keeps one database per account — so every
// read here filters to a profile in the handler. Returning the store's rows
// unfiltered would show every household member's devices to everyone.
type DeviceHandler struct {
storeProvider userstore.UserStoreProvider
// EventsHub, when set, receives a user_settings.changed event for every
// key a forget or clear removed. Nil (as in tests) skips publishing.
EventsHub *evt.Hub
// UserRepo and ProfileTokens enable ?scope=household for the household
// parent. Both nil means the whole-household read is simply unavailable —
// never that it is unguarded.
UserRepo userLookup
ProfileTokens *access.ProfileTokenService
}
func NewDeviceHandler(provider userstore.UserStoreProvider) *DeviceHandler {
return &DeviceHandler{storeProvider: provider}
}
type deviceResponse struct {
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
LastSeenAt string `json:"last_seen_at"`
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
// IsCurrentDevice marks the device this request came from, so a client can
// say "you're here" without repeating the header-matching rule.
IsCurrentDevice bool `json:"is_current_device"`
// ChangedCount is how many settings this (profile, device) pair overrides.
// It is the one number a device list needs: everything else about a device
// is either its identity or its last-seen time.
ChangedCount int `json:"changed_count"`
}
type deviceListResponse struct {
Devices []deviceResponse `json:"devices"`
}
// HandleListDevices handles GET /devices.
func (h *DeviceHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
registry, ok := store.(userstore.DeviceRegistry)
if !ok {
writeJSON(w, http.StatusOK, deviceListResponse{Devices: []deviceResponse{}})
return
}
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return
}
// Household scope is opt-in and guarded. The default is this profile alone,
// so the ordinary screen stays private by construction rather than by a
// caller remembering to ask for less.
household := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("scope")), "household")
if household {
allowed, err := canManageHousehold(r, store, h.UserRepo, h.ProfileTokens)
if err != nil {
writeProfileManagementPermissionError(w, err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden",
"Viewing the household's devices requires the primary profile or admin access")
return
}
}
devices, err := registry.ListDevices(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
return
}
// One pass over the stored values rather than a count query per device.
counts, err := deviceOverrideCounts(r, store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read device settings")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
slog.WarnContext(r.Context(), "device list profile lookup failed",
"component", "api", "error", err)
profileNames = map[string]string{}
}
currentDeviceID := deviceMetadataFromRequest(r).DeviceID
resp := deviceListResponse{Devices: make([]deviceResponse, 0, len(devices))}
for _, device := range devices {
if !household && device.ProfileID != profileID {
continue
}
resp.Devices = append(resp.Devices, deviceResponse{
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
DevicePlatform: device.DevicePlatform,
LastSeenAt: device.LastSeenAt,
ProfileID: device.ProfileID,
ProfileName: profileNames[device.ProfileID],
IsCurrentDevice: device.DeviceID == currentDeviceID,
ChangedCount: counts[deviceKey{profileID: device.ProfileID, deviceID: device.DeviceID}],
})
}
// ListDevices already orders by last_seen_at; keep that and make the tie
// break deterministic so a client's list does not reshuffle between reads.
sort.SliceStable(resp.Devices, func(i, j int) bool {
if resp.Devices[i].LastSeenAt != resp.Devices[j].LastSeenAt {
return resp.Devices[i].LastSeenAt > resp.Devices[j].LastSeenAt
}
return resp.Devices[i].DeviceID < resp.Devices[j].DeviceID
})
writeJSON(w, http.StatusOK, resp)
}
// HandleForgetDevice handles DELETE /devices/{device_id}: clear the device's
// settings and drop it from the registry.
func (h *DeviceHandler) HandleForgetDevice(w http.ResponseWriter, r *http.Request) {
h.removeDevice(w, r, true)
}
// HandleClearDeviceSettings handles DELETE /devices/{device_id}/settings:
// return the device to the profile's own values without forgetting it.
func (h *DeviceHandler) HandleClearDeviceSettings(w http.ResponseWriter, r *http.Request) {
h.removeDevice(w, r, false)
}
func (h *DeviceHandler) removeDevice(w http.ResponseWriter, r *http.Request, forget bool) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return
}
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "A device id is required")
return
}
// The household parent may forget or clear a family member's device, on the
// same guard the settings routes use.
if named := strings.TrimSpace(r.URL.Query().Get("profile_id")); named != "" && named != profileID {
allowed, err := canManageHousehold(r, store, h.UserRepo, h.ProfileTokens)
if err != nil {
writeProfileManagementPermissionError(w, err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden",
"Managing another profile's devices requires the primary profile or admin access")
return
}
profile, err := store.GetProfile(r.Context(), named)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load profile")
return
}
if profile == nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
return
}
profileID = named
}
// A device this profile never registered is not this caller's to remove.
// 404 rather than 403: a 403 would confirm the id exists somewhere.
//
// "This profile has no trace of it" covers both a device that never existed
// and one already forgotten, which is what makes a repeated forget answer
// 204 rather than inventing a failure for work it already did. A device
// belonging to *another* profile is equally traceless here, so it takes the
// same path and learns nothing about that device's existence.
registry, isRegistry := store.(userstore.DeviceRegistry)
owned := false
if isRegistry {
exists, err := registry.DeviceExists(r.Context(), profileID, deviceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up device")
return
}
owned = exists
}
keys, err := h.deviceSettingKeys(r, store, profileID, deviceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read device settings")
return
}
if !owned && len(keys) == 0 {
writeError(w, http.StatusNotFound, "not_found", "Device not found")
return
}
if _, err := store.DeleteSettingValuesForDevice(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear device settings")
return
}
// The legacy string-keyed device settings are a second generation of the
// same data; clearing one without the other would leave the device
// half-reset.
if err := store.DeleteAllDeviceSettings(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear device settings")
return
}
if forget && isRegistry {
if err := registry.ForgetDevice(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to forget device")
return
}
}
userID := apimw.GetUserID(r.Context())
for _, key := range keys {
publishUserSettingsEvent(r.Context(), h.EventsHub, userID, profileID,
key, string(settingscontract.ScopeProfileDevice))
}
action := "clear_device"
if forget {
action = "forget_device"
}
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: action,
ActorProfileID: actingProfileID(r.Context()),
TargetProfileID: profileID,
TargetUserID: userID,
DeviceID: deviceID,
Scope: string(settingscontract.ScopeProfileDevice),
})
w.WriteHeader(http.StatusNoContent)
}
type deviceKey struct {
profileID string
deviceID string
}
// deviceOverrideCounts counts stored profile_device rows per (profile, device).
// ListAllSettingValues spans the whole account, so the caller filters the
// result to the profile it is answering for.
func deviceOverrideCounts(r *http.Request, store userstore.UserStore) (map[deviceKey]int, error) {
values, err := store.ListAllSettingValues(r.Context())
if err != nil {
return nil, err
}
counts := make(map[deviceKey]int)
for _, value := range values {
if value.Scope != settingscontract.ScopeProfileDevice {
continue
}
counts[deviceKey{profileID: value.ProfileID, deviceID: value.DeviceID}]++
}
return counts, nil
}
// deviceSettingKeys returns the canonical keys stored for one device, so a
// clear can publish an invalidation per key that actually moved.
func (h *DeviceHandler) deviceSettingKeys(
r *http.Request, store userstore.UserStore, profileID, deviceID string,
) ([]string, error) {
values, err := store.ListAllSettingValues(r.Context())
if err != nil {
return nil, err
}
var keys []string
for _, value := range values {
if value.Scope != settingscontract.ScopeProfileDevice {
continue
}
if value.ProfileID != profileID || value.DeviceID != deviceID {
continue
}
keys = append(keys, value.Key)
}
return keys, nil
}
func (h *DeviceHandler) storeFor(w http.ResponseWriter, r *http.Request) (userstore.UserStore, bool) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return nil, false
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return nil, false
}
return store, true
}
-405
View File
@@ -1,405 +0,0 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
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/models"
"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 newDevicesTestHandler(t *testing.T) (*DeviceHandler, 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)
ctx := context.Background()
for _, p := range []userstore.Profile{
{ID: "profile-1", Name: "Sam", IsPrimary: true},
{ID: "profile-2", Name: "Robin"},
} {
if err := store.CreateProfile(ctx, p); err != nil {
t.Fatalf("create profile %s: %v", p.ID, err)
}
}
return NewDeviceHandler(testUserStoreProvider{store: store}), store
}
func seedDevice(t *testing.T, store userstore.UserStore, profileID, deviceID, name string) {
t.Helper()
registry, ok := store.(userstore.DeviceRegistry)
if !ok {
t.Fatal("store does not implement DeviceRegistry")
}
if err := registry.RegisterDevice(context.Background(), userstore.DeviceEntry{
ProfileID: profileID, DeviceID: deviceID, DeviceName: name, DevicePlatform: "web",
}); err != nil {
t.Fatalf("registering %s: %v", deviceID, err)
}
}
func seedDeviceValue(t *testing.T, store userstore.UserStore, profileID, deviceID, key, value string) {
t.Helper()
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: key,
Scope: settingscontract.ScopeProfileDevice,
ProfileID: profileID,
DeviceID: deviceID,
}, json.RawMessage(value)); err != nil {
t.Fatalf("seeding %s on %s: %v", key, deviceID, err)
}
}
func devicesRequest(method, target, profileID string) *http.Request {
req := httptest.NewRequest(method, target, nil)
req.Header.Set(deviceIDHeader, "device-1")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
return req.WithContext(apimw.SetProfileID(ctx, profileID))
}
func listDevices(t *testing.T, h *DeviceHandler, query, profileID string) deviceListResponse {
t.Helper()
target := "/devices"
if query != "" {
target += "?" + query
}
rec := httptest.NewRecorder()
h.HandleListDevices(rec, devicesRequest(http.MethodGet, target, profileID))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", target, rec.Code, rec.Body.String())
}
var body deviceListResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
return body
}
// TestListDevices_FiltersToCallingProfile is the security test for this
// endpoint. ListDevices is account-wide by construction in both backends —
// "WHERE user_id" in Postgres and no WHERE at all in the per-user SQLite — so a
// passthrough would show every household member's devices to everyone.
func TestListDevices_FiltersToCallingProfile(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
body := listDevices(t, handler, "", "profile-1")
if len(body.Devices) != 1 {
t.Fatalf("returned %d devices, want 1: %+v", len(body.Devices), body.Devices)
}
if body.Devices[0].DeviceID != "device-1" {
t.Errorf("returned device %q, want device-1", body.Devices[0].DeviceID)
}
for _, device := range body.Devices {
if device.ProfileID != "profile-1" {
t.Errorf("leaked device %q from profile %q", device.DeviceID, device.ProfileID)
}
}
}
func TestListDevices_CountsChangedSettings(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Laptop")
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`)
seedDeviceValue(t, store, "profile-1", "device-2", "playback.subtitle_mode", `"always"`)
// Another profile's row on the same device must not be counted.
seedDeviceValue(t, store, "profile-2", "device-2", "player.seek_cache_enabled", `false`)
body := listDevices(t, handler, "", "profile-1")
counts := map[string]int{}
for _, device := range body.Devices {
counts[device.DeviceID] = device.ChangedCount
}
if counts["device-2"] != 2 {
t.Errorf("device-2 changed_count = %d, want 2", counts["device-2"])
}
if counts["device-1"] != 0 {
t.Errorf("device-1 changed_count = %d, want 0", counts["device-1"])
}
}
func TestListDevices_MarksCurrentDevice(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-1", "This browser")
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
body := listDevices(t, handler, "", "profile-1")
for _, device := range body.Devices {
want := device.DeviceID == "device-1"
if device.IsCurrentDevice != want {
t.Errorf("device %q is_current_device = %v, want %v",
device.DeviceID, device.IsCurrentDevice, want)
}
}
}
func TestListDevices_IncludesProfileName(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Laptop")
body := listDevices(t, handler, "", "profile-1")
if len(body.Devices) != 1 || body.Devices[0].ProfileName != "Sam" {
t.Errorf("profile_name = %q, want Sam", body.Devices[0].ProfileName)
}
}
func routeDevice(
t *testing.T, h *DeviceHandler, method, target, deviceID, profileID string,
handle func(http.ResponseWriter, *http.Request),
) *httptest.ResponseRecorder {
t.Helper()
req := devicesRequest(method, target, profileID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("device_id", deviceID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handle(rec, req)
return rec
}
func TestForgetDevice_RemovesSettingsAndRegistryRow(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2",
"profile-1", handler.HandleForgetDevice)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-2")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if exists {
t.Error("registry row survived forget")
}
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" {
t.Errorf("setting row survived forget on device %q", got)
}
}
func TestForgetDevice_RejectsOtherProfilesDevice(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
seedDeviceValue(t, store, "profile-2", "device-9", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-9", "device-9",
"profile-1", handler.HandleForgetDevice)
if rec.Code != http.StatusNotFound {
t.Fatalf("DELETE another profile's device = %d, want 404", rec.Code)
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-2", "device-9")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if !exists {
t.Error("another profile's device was removed")
}
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "device-9" {
t.Errorf("another profile's setting row was removed (device %q)", got)
}
}
// A repeated forget reports 404, not 500 or a partial delete: once the device
// is gone this profile has no trace of it, which is indistinguishable from a
// device that was never here — and deliberately so, since the same answer is
// what keeps another profile's device ids from being probeable.
func TestForgetDevice_SecondCallIsNotFound(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
if rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2",
"profile-1", handler.HandleForgetDevice); rec.Code != http.StatusNoContent {
t.Fatalf("first DELETE = %d, want 204: %s", rec.Code, rec.Body.String())
}
if rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2",
"profile-1", handler.HandleForgetDevice); rec.Code != http.StatusNotFound {
t.Fatalf("second DELETE = %d, want 404", rec.Code)
}
}
// Forgetting a device two profiles share removes only the caller's half.
func TestForgetDevice_LeavesOtherProfilesRowOnSharedDevice(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "shared-tv", "Living Room TV")
seedDevice(t, store, "profile-2", "shared-tv", "Living Room TV")
seedDeviceValue(t, store, "profile-2", "shared-tv", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/shared-tv", "shared-tv",
"profile-1", handler.HandleForgetDevice)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
stillThere, err := registry.DeviceExists(context.Background(), "profile-2", "shared-tv")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if !stillThere {
t.Error("forgetting one profile's half removed the other profile's row")
}
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "shared-tv" {
t.Errorf("the other profile's setting row was removed (device %q)", got)
}
}
func TestClearDeviceSettings_KeepsRegistryRow(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2/settings", "device-2",
"profile-1", handler.HandleClearDeviceSettings)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-2")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if !exists {
t.Error("registry row was removed; clear must keep the device")
}
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" {
t.Errorf("setting row survived clear on device %q", got)
}
}
// --- Household scope ---
func householdDevicesHandler(t *testing.T) (*DeviceHandler, userstore.UserStore) {
t.Helper()
handler, store := newDevicesTestHandler(t)
handler.UserRepo = stubUserRepo{user: &models.User{ID: 1}}
handler.ProfileTokens = access.NewProfileTokenService("test-secret-value-at-least-32-chars", 0)
return handler, store
}
func TestListDevices_PrimarySeesHouseholdWhenRequested(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
body := listDevices(t, handler, "scope=household", "profile-1")
if len(body.Devices) != 2 {
t.Fatalf("household scope returned %d devices, want 2: %+v", len(body.Devices), body.Devices)
}
names := map[string]string{}
for _, device := range body.Devices {
names[device.DeviceID] = device.ProfileName
}
if names["device-9"] != "Robin" {
t.Errorf("device-9 profile_name = %q, want Robin", names["device-9"])
}
}
func TestListDevices_NonPrimaryCannotRequestHousehold(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
rec := httptest.NewRecorder()
handler.HandleListDevices(rec, devicesRequest(http.MethodGet, "/devices?scope=household", "profile-2"))
if rec.Code != http.StatusForbidden {
t.Fatalf("non-primary household read = %d, want 403: %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "device-1") {
t.Error("refusal leaked another profile's device")
}
}
// Default scope stays private even for the household parent, so the ordinary
// screen cannot show the family's devices by forgetting to ask for less.
func TestListDevices_PrimaryDefaultsToOwnProfile(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
body := listDevices(t, handler, "", "profile-1")
if len(body.Devices) != 1 || body.Devices[0].DeviceID != "device-1" {
t.Fatalf("default scope returned %+v, want only device-1", body.Devices)
}
}
func TestForgetDevice_PrimaryMayForgetHouseholdDevice(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
seedDeviceValue(t, store, "profile-2", "device-9", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-9?profile_id=profile-2",
"device-9", "profile-1", handler.HandleForgetDevice)
if rec.Code != http.StatusNoContent {
t.Fatalf("primary forgetting a household device = %d: %s", rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-2", "device-9")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if exists {
t.Error("device survived the household forget")
}
}
func TestForgetDevice_NonPrimaryCannotForgetSiblingsDevice(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-1?profile_id=profile-1",
"device-1", "profile-2", handler.HandleForgetDevice)
if rec.Code != http.StatusForbidden {
t.Fatalf("non-primary forgetting a sibling's device = %d, want 403: %s",
rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-1")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if !exists {
t.Error("a non-primary profile removed a sibling's device")
}
}
+15 -30
View File
@@ -8,8 +8,6 @@ import (
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -43,24 +41,18 @@ type DiagnosticsService interface {
}
type DiagnosticsHandler struct {
service DiagnosticsService
adminService AdminDiagnosticsService
inflight *diagnosticsInFlightLimiter
chunkSessions *diagnosticsChunkSessions
logger *slog.Logger
service DiagnosticsService
adminService AdminDiagnosticsService
inflight *diagnosticsInFlightLimiter
logger *slog.Logger
}
func NewDiagnosticsHandler(service DiagnosticsService) *DiagnosticsHandler {
handler := &DiagnosticsHandler{
service: service,
inflight: newDiagnosticsInFlightLimiter(4),
chunkSessions: newDiagnosticsChunkSessions(filepath.Join(os.TempDir(), "silo-diagnostics-uploads")),
logger: slog.Default(),
service: service,
inflight: newDiagnosticsInFlightLimiter(4),
logger: slog.Default(),
}
// Reclaim abandoned chunk spool bytes on a timer, not only from later API
// traffic. The handler lives for the process, so the sweeper needs no
// stop signal.
handler.chunkSessions.startSweeper(nil)
if adminService, ok := service.(AdminDiagnosticsService); ok {
handler.adminService = adminService
}
@@ -80,14 +72,9 @@ func (h *DiagnosticsHandler) HandleStatus(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, status)
}
// extendDiagnosticsUploadDeadlines lifts this request's read (and, when
// includeWrite, write) deadline to diagnosticsUploadReadTimeout. The
// server-wide ReadTimeout (30s) is too short for slow bundle/chunk uploads,
// and the WriteTimeout (120s) can expire after Ingest has already stored the
// report — the lost 201 would make the client retry an upload that
// succeeded. Shared by the single-shot upload, chunk PUT (read only), and
// chunked complete (both).
func (h *DiagnosticsHandler) extendDiagnosticsUploadDeadlines(w http.ResponseWriter, r *http.Request, includeWrite bool) {
func (h *DiagnosticsHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
// Extend the read deadline for this request only; the server-wide
// ReadTimeout is too short for slow, large bundle uploads (see the constant).
rc := http.NewResponseController(w)
if err := rc.SetReadDeadline(time.Now().Add(diagnosticsUploadReadTimeout)); err != nil {
h.diagnosticsLogger().WarnContext(r.Context(), "diagnostics upload read deadline not extended",
@@ -95,19 +82,17 @@ func (h *DiagnosticsHandler) extendDiagnosticsUploadDeadlines(w http.ResponseWri
"error", err,
)
}
if !includeWrite {
return
}
// Extend the write deadline the same way. cmd/silo's integrated server sets
// WriteTimeout 120s, but a slow upload can take longer than that within the
// read window above; Ingest may store and mark the report ready and then the
// final writeJSON would miss the 120s write deadline, so the client sees a
// timeout and retries a report that already succeeded.
if err := rc.SetWriteDeadline(time.Now().Add(diagnosticsUploadReadTimeout)); err != nil {
h.diagnosticsLogger().WarnContext(r.Context(), "diagnostics upload write deadline not extended",
"component", "diagnostics",
"error", err,
)
}
}
func (h *DiagnosticsHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
h.extendDiagnosticsUploadDeadlines(w, r, true)
userID, ok := diagnosticsUserID(w, r)
if !ok {
@@ -1,482 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/diagnostics"
"github.com/Silo-Server/silo-server/internal/uploads"
)
// Chunked diagnostics upload: a fallback path for bundles that a reverse proxy
// in front of Silo refuses as a single request (nginx's default
// client_max_body_size is 1 MiB; /diagnostics/status advertises bundles up to
// 10 MiB). The client:
//
// 1. POST /diagnostics/reports/uploads {manifest, bundle_bytes}
// 2. PUT /diagnostics/reports/uploads/{id}/chunks/{index} raw bundle bytes, ≤ upload_chunk_bytes each
// 3. POST /diagnostics/reports/uploads/{id}/complete → same 201 as the single-shot upload
// DELETE /diagnostics/reports/uploads/{id} best-effort abandon
//
// The assembled bundle goes through the exact same Ingest path as the
// single-shot endpoint, so every content check (manifest contract, archive
// sha/bytes/entries, quotas, profile attribution) applies identically. The
// manifest is validated only for size at init; Ingest judges it at complete.
//
// Sessions spool to local disk and expire after diagnosticsChunkSessionTTL of
// inactivity (chunk arrivals refresh the deadline). They do NOT hold the
// shared in-flight ingest slot while chunks stream in — only complete
// acquires it, for the ingest itself — so a client that dies mid-upload
// leaves nothing behind but spool bytes the TTL sweep reclaims, and never
// blocks its user's later uploads. Concurrency is bounded by one session per
// user (a new init replaces the previous session) plus a global session cap,
// both reserved atomically in init.
//
// Session state (ownership map + spool files) is process-local. That is fine
// for the integrated single-process server this feature targets; a
// multi-replica API deployment would need sticky routing for the few requests
// of one chunked upload, or a chunk PUT landing on another replica answers
// 404 and the client restarts from init.
const (
// diagnosticsChunkSessionTTL bounds how long an in-progress chunked upload
// may sit idle before its spooled bytes are reclaimed. Generous enough for
// a slow uplink to push 10 MiB in 768 KiB chunks, small enough that
// abandoned sessions don't hold disk for long.
diagnosticsChunkSessionTTL = 15 * time.Minute
// diagnosticsChunkSessionCap bounds concurrent chunked sessions across all
// users, capping worst-case spool disk at cap × max_bundle_bytes
// (≈ 160 MiB at defaults).
diagnosticsChunkSessionCap = 16
)
type diagnosticsChunkInitRequest struct {
// Manifest is the same part-1 manifest the multipart endpoint takes,
// embedded verbatim. Deferring all content validation to Ingest keeps one
// authority for what a valid manifest is.
Manifest json.RawMessage `json:"manifest"`
// BundleBytes is the exact assembled bundle size the client will upload.
// Declared up front so over-limit uploads fail at init, before any chunk
// bytes are spent.
BundleBytes int64 `json:"bundle_bytes"`
}
type diagnosticsChunkInitResponse struct {
UploadID string `json:"upload_id"`
ChunkBytes int64 `json:"chunk_bytes"`
TotalChunks int `json:"total_chunks"`
ExpiresAt string `json:"expires_at"`
}
type diagnosticsChunkStateResponse struct {
ReceivedChunks int `json:"received_chunks"`
TotalChunks int `json:"total_chunks"`
}
// diagnosticsChunkSessions pairs the generic upload session manager with the
// per-session diagnostics state it does not track: the manifest captured at
// init and the owning user (sessions must not be readable or writable across
// accounts).
type diagnosticsChunkSessions struct {
manager *uploads.Manager
mu sync.Mutex
owners map[string]diagnosticsChunkOwner
byUser map[int]string
// reserved counts init calls that have claimed a cap slot but not yet
// registered their created session in owners. Counting reservations and
// registrations together makes the per-user + global-cap admission atomic:
// concurrent inits cannot each pass the checks before any of them
// registers.
reserved map[int]struct{}
}
type diagnosticsChunkOwner struct {
userID int
manifest []byte
}
func newDiagnosticsChunkSessions(spoolDir string) *diagnosticsChunkSessions {
sessions := &diagnosticsChunkSessions{
manager: uploads.NewManager(uploads.ManagerOptions{
RootDir: spoolDir,
TTL: diagnosticsChunkSessionTTL,
MaxChunkSize: diagnostics.UploadChunkBytes,
// MaxSize is enforced per-init against the live max_bundle_bytes
// setting; the manager-level bound is just a hard backstop.
MaxSize: 256 << 20,
}),
owners: make(map[string]diagnosticsChunkOwner),
byUser: make(map[int]string),
reserved: make(map[int]struct{}),
}
// A restart leaves the previous process's spool directories on disk with
// no session map entry to ever expire them; reclaim them now.
sessions.manager.ReclaimOrphanedDirs()
return sessions
}
// startSweeper begins the timed expiry sweep. Split from the constructor so
// tests can run without background goroutines.
func (s *diagnosticsChunkSessions) startSweeper(stop <-chan struct{}) {
s.manager.StartExpirySweeper(time.Minute, stop)
}
// owner returns the session owner entry when id belongs to userID.
func (s *diagnosticsChunkSessions) owner(id string, userID int) (diagnosticsChunkOwner, bool) {
s.mu.Lock()
defer s.mu.Unlock()
owner, ok := s.owners[id]
if !ok || owner.userID != userID {
return diagnosticsChunkOwner{}, false
}
return owner, true
}
// reserve atomically claims the user's session slot and a global cap slot,
// evicting the user's own previous session first (its id is returned for the
// caller to cancel outside the lock — the caller cancels it whether or not
// admission succeeds, since the eviction already happened). A false capOK
// means the cap is full, or a concurrent init by the same user holds an
// unfinished reservation — that racing call must not create a second session.
//
// The cap counts live sessions, unfinished reservations, AND the manager's
// detached-writer sessions: a canceled session whose slow chunk writer is
// still draining holds real disk and a connection until the writer's read
// deadline, so a cancel-and-reinit loop must stall at the cap rather than
// stack unbounded live writers behind it.
func (s *diagnosticsChunkSessions) reserve(userID int) (previousID string, capOK bool) {
detached := s.manager.DetachedWriterSessions()
s.mu.Lock()
defer s.mu.Unlock()
if _, inFlight := s.reserved[userID]; inFlight {
return "", false
}
if id, ok := s.byUser[userID]; ok {
previousID = id
delete(s.owners, id)
delete(s.byUser, userID)
}
if len(s.owners)+len(s.reserved)+detached >= diagnosticsChunkSessionCap {
return previousID, false
}
s.reserved[userID] = struct{}{}
return previousID, true
}
// commit registers the created session under an existing reservation.
func (s *diagnosticsChunkSessions) commit(userID int, id string, manifest []byte) {
s.mu.Lock()
delete(s.reserved, userID)
s.owners[id] = diagnosticsChunkOwner{userID: userID, manifest: manifest}
s.byUser[userID] = id
s.mu.Unlock()
}
// unreserve rolls back a reservation whose session creation failed.
func (s *diagnosticsChunkSessions) unreserve(userID int) {
s.mu.Lock()
delete(s.reserved, userID)
s.mu.Unlock()
}
// drop removes the owner entry. Idempotent; the manager session is the
// caller's to cancel.
func (s *diagnosticsChunkSessions) drop(id string) {
s.mu.Lock()
owner, ok := s.owners[id]
delete(s.owners, id)
if ok && s.byUser[owner.userID] == id {
delete(s.byUser, owner.userID)
}
s.mu.Unlock()
}
// sweepExpired drops owner entries whose manager session has expired or is
// gone, so the owner map (and the cap headroom it counts against) doesn't
// leak alongside the manager's own spool cleanup. Called from init, the only
// entry point that needs the freed headroom.
func (s *diagnosticsChunkSessions) sweepExpired() {
s.mu.Lock()
stale := make([]string, 0, len(s.owners))
for id := range s.owners {
if _, err := s.manager.Peek(id); errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
stale = append(stale, id)
}
}
s.mu.Unlock()
for _, id := range stale {
s.drop(id)
}
}
// HandleChunkedUploadInit handles POST /diagnostics/reports/uploads.
func (h *DiagnosticsHandler) HandleChunkedUploadInit(w http.ResponseWriter, r *http.Request) {
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
status, ok := h.diagnosticsUploadStatus(w, r, userID)
if !ok {
return
}
maxBundleBytes := status.MaxBundleBytes
if maxBundleBytes <= 0 {
maxBundleBytes = diagnostics.DefaultMaxBundleBytes
}
// Manifest cap plus a small envelope allowance keeps init requests tiny —
// they must themselves fit under restrictive proxy body caps.
r.Body = http.MaxBytesReader(w, r.Body, diagnostics.MaxManifestBytes+diagnosticsMultipartOverheadBytes)
var req diagnosticsChunkInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics manifest is too large")
return
}
writeError(w, http.StatusBadRequest, "invalid_bundle", "Invalid diagnostics upload init")
return
}
if len(req.Manifest) == 0 || int64(len(req.Manifest)) > diagnostics.MaxManifestBytes {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics manifest is too large")
return
}
if req.BundleBytes <= 0 {
writeError(w, http.StatusBadRequest, "invalid_bundle", "bundle_bytes must be positive")
return
}
if req.BundleBytes > maxBundleBytes {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics upload is too large")
return
}
h.chunkSessions.sweepExpired()
// Atomically evict the user's previous session (a new init replaces it,
// so a client that died mid-upload can start over immediately) and claim
// a cap slot. Cancel the evicted spool outside the lock either way.
previousID, capOK := h.chunkSessions.reserve(userID)
if previousID != "" {
h.abortChunkedUpload(previousID)
}
if !capOK {
h.logRejected(r.Context(), userID, "busy")
w.Header().Set("Retry-After", diagnosticsBusyRetryAfter)
writeError(w, http.StatusServiceUnavailable, "busy", "Diagnostics upload capacity is busy")
return
}
session, err := h.chunkSessions.manager.Create(uploads.CreateRequest{
Filename: "bundle.tar.gz",
SizeBytes: req.BundleBytes,
ChunkSize: diagnostics.UploadChunkBytes,
})
if err != nil {
h.chunkSessions.unreserve(userID)
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
h.chunkSessions.commit(userID, session.ID, append([]byte(nil), req.Manifest...))
writeJSON(w, http.StatusCreated, diagnosticsChunkInitResponse{
UploadID: session.ID,
ChunkBytes: session.ChunkSize,
TotalChunks: session.TotalChunks,
ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339),
})
}
// HandleChunkedUploadChunk handles PUT /diagnostics/reports/uploads/{upload_id}/chunks/{chunk_index}.
func (h *DiagnosticsHandler) HandleChunkedUploadChunk(w http.ResponseWriter, r *http.Request) {
// The integrated server's 30s ReadTimeout kills a 768 KiB chunk arriving
// below ~26 KiB/s — exactly the slow uplinks the chunked fallback exists
// for. The write deadline needs lifting too: the 120s WriteTimeout starts
// at request start, so on a sufficiently slow uplink the stored chunk's
// JSON acknowledgement would miss it and the client would retry an
// already-accepted chunk.
h.extendDiagnosticsUploadDeadlines(w, r, true)
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
chunkIndex, err := strconv.Atoi(chi.URLParam(r, "chunk_index"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid chunk index")
return
}
if _, ok := h.chunkSessions.owner(uploadID, userID); !ok {
writeError(w, http.StatusNotFound, "upload_error", "Upload session not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, diagnostics.UploadChunkBytes+1)
defer r.Body.Close()
session, err := h.chunkSessions.manager.PutChunk(r.Context(), uploadID, chunkIndex, r.Body, r.ContentLength)
if err != nil {
if errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
h.chunkSessions.drop(uploadID)
}
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
writeJSON(w, http.StatusOK, diagnosticsChunkStateResponse{
ReceivedChunks: session.ReceivedChunks,
TotalChunks: session.TotalChunks,
})
}
// HandleChunkedUploadComplete handles POST /diagnostics/reports/uploads/{upload_id}/complete.
func (h *DiagnosticsHandler) HandleChunkedUploadComplete(w http.ResponseWriter, r *http.Request) {
// Ingest + object-store write can outlast the server's 120s WriteTimeout;
// without the extension the stored report's 201 would be lost and the
// client would retry an upload that already succeeded (see the single-shot
// handler).
h.extendDiagnosticsUploadDeadlines(w, r, true)
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
owner, ok := h.chunkSessions.owner(uploadID, userID)
if !ok {
writeError(w, http.StatusNotFound, "upload_error", "Upload session not found")
return
}
// Availability can have changed since init (admin toggle, storage loss);
// re-check so a completed spool is not ingested into a disabled feature.
// Only a definitive non-available answer discards the session — a
// transient status load failure (500) keeps it so a retried complete can
// succeed without re-uploading every chunk.
status, statusErr := h.service.Status(r.Context(), userID)
if statusErr != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load diagnostics status")
return
}
if status.Status != diagnostics.StatusAvailable {
h.abortChunkedUpload(uploadID)
switch status.Status {
case diagnostics.StatusDisabled:
writeError(w, http.StatusForbidden, "disabled", "Diagnostics uploads are disabled")
default:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not configured")
}
return
}
// The ingest itself shares capacity with single-shot uploads. Check
// before consuming the manager session so a busy answer leaves the
// session intact for a client-side retry of complete.
release, acquired := h.inflight.acquire(userID)
if !acquired {
h.logRejected(r.Context(), userID, "busy")
w.Header().Set("Retry-After", diagnosticsBusyRetryAfter)
writeError(w, http.StatusServiceUnavailable, "busy", "Diagnostics upload capacity is busy")
return
}
defer release()
upload, err := h.chunkSessions.manager.Complete(uploadID)
if err != nil {
if errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
h.chunkSessions.drop(uploadID)
}
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
// The manager session is consumed; a failed ingest is retried by the
// client from a fresh init, never by re-completing a spent session.
defer h.chunkSessions.drop(uploadID)
defer upload.Cleanup()
bundle, err := os.Open(upload.Path)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Diagnostics upload failed")
return
}
defer bundle.Close()
profileID := strings.TrimSpace(r.Header.Get("X-Profile-Id"))
var profileIDPtr *string
if profileID != "" {
profileIDPtr = &profileID
}
result, err := h.service.Ingest(r.Context(), userID, profileIDPtr, owner.manifest, io.Reader(bundle))
if err != nil {
writeDiagnosticsServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, result)
}
// HandleChunkedUploadAbort handles DELETE /diagnostics/reports/uploads/{upload_id}.
func (h *DiagnosticsHandler) HandleChunkedUploadAbort(w http.ResponseWriter, r *http.Request) {
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
if _, ok := h.chunkSessions.owner(uploadID, userID); !ok {
// Unknown or foreign session: abort is a best-effort courtesy, and
// idempotent success leaks nothing about other users' session ids.
w.WriteHeader(http.StatusNoContent)
return
}
h.abortChunkedUpload(uploadID)
w.WriteHeader(http.StatusNoContent)
}
func (h *DiagnosticsHandler) abortChunkedUpload(uploadID string) {
if err := h.chunkSessions.manager.Cancel(uploadID); err != nil && !errors.Is(err, uploads.ErrNotFound) {
h.diagnosticsLogger().Warn("diagnostics chunked upload cancel failed",
"component", "diagnostics",
"error", err,
)
}
h.chunkSessions.drop(uploadID)
}
// diagnosticsUploadStatus loads the feature status and writes the
// disabled/storage-unavailable rejection when uploads cannot proceed,
// mirroring the single-shot endpoint's gate.
func (h *DiagnosticsHandler) diagnosticsUploadStatus(w http.ResponseWriter, r *http.Request, userID int) (diagnostics.Status, bool) {
status, err := h.service.Status(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load diagnostics status")
return diagnostics.Status{}, false
}
switch status.Status {
case diagnostics.StatusDisabled:
writeError(w, http.StatusForbidden, "disabled", "Diagnostics uploads are disabled")
return diagnostics.Status{}, false
case diagnostics.StatusStorageUnavailable:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not configured")
return diagnostics.Status{}, false
case diagnostics.StatusAvailable:
return status, true
default:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not available")
return diagnostics.Status{}, false
}
}
@@ -1,424 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"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/diagnostics"
)
var errFakeStatusUnavailable = errors.New("status store temporarily unavailable")
// newChunkedTestHandler returns a handler whose chunk spool lives in the
// test's temp dir so parallel test runs never share state.
func newChunkedTestHandler(t *testing.T, service DiagnosticsService) *DiagnosticsHandler {
t.Helper()
handler := NewDiagnosticsHandler(service)
handler.chunkSessions = newDiagnosticsChunkSessions(t.TempDir())
return handler
}
func chunkedRouter(handler *DiagnosticsHandler) chi.Router {
r := chi.NewRouter()
r.Post("/uploads", handler.HandleChunkedUploadInit)
r.Put("/uploads/{upload_id}/chunks/{chunk_index}", handler.HandleChunkedUploadChunk)
r.Post("/uploads/{upload_id}/complete", handler.HandleChunkedUploadComplete)
r.Delete("/uploads/{upload_id}", handler.HandleChunkedUploadAbort)
return r
}
func doChunked(t *testing.T, router chi.Router, method, path string, body []byte, claims *auth.Claims) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer token")
if method == http.MethodPost && strings.HasSuffix(path, "/uploads") {
req.Header.Set("Content-Type", "application/json")
}
if claims != nil {
req = req.WithContext(apimw.SetClaims(req.Context(), claims))
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func initChunkedUpload(t *testing.T, router chi.Router, bundleBytes int, claims *auth.Claims) diagnosticsChunkInitResponse {
t.Helper()
body := []byte(fmt.Sprintf(`{"manifest":{"ok":true},"bundle_bytes":%d}`, bundleBytes))
rec := doChunked(t, router, http.MethodPost, "/uploads", body, claims)
if rec.Code != http.StatusCreated {
t.Fatalf("init status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String())
}
var resp diagnosticsChunkInitResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode init response: %v", err)
}
return resp
}
func TestDiagnosticsChunkedUploadHappyPath(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)+512)
session := initChunkedUpload(t, router, len(bundle), accessClaims())
if session.ChunkBytes != diagnostics.UploadChunkBytes {
t.Fatalf("chunk_bytes = %d, want %d", session.ChunkBytes, diagnostics.UploadChunkBytes)
}
if session.TotalChunks != 2 {
t.Fatalf("total_chunks = %d, want 2", session.TotalChunks)
}
for index := 0; index < session.TotalChunks; index++ {
start := int64(index) * session.ChunkBytes
end := start + session.ChunkBytes
if end > int64(len(bundle)) {
end = int64(len(bundle))
}
rec := doChunked(t, router, http.MethodPut,
fmt.Sprintf("/uploads/%s/chunks/%d", session.UploadID, index),
bundle[start:end], accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk %d status = %d; body=%s", index, rec.Code, rec.Body.String())
}
}
rec := doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusCreated {
t.Fatalf("complete status = %d; body=%s", rec.Code, rec.Body.String())
}
var resp diagnostics.IngestResult
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode complete response: %v", err)
}
if resp.ShortID != "SILO-ABCDEF123456" {
t.Fatalf("short_id = %q", resp.ShortID)
}
if service.ingestCalls != 1 {
t.Fatalf("ingest calls = %d, want 1", service.ingestCalls)
}
if got := string(service.lastManifest); got != `{"ok":true}` {
t.Fatalf("manifest passed to ingest = %q", got)
}
if int(service.lastBundleBytes) != len(bundle) {
t.Fatalf("bundle bytes passed to ingest = %d, want %d", service.lastBundleBytes, len(bundle))
}
// The ingest slot must be free again after completion.
release, acquired := handler.inflight.acquire(42)
if !acquired {
t.Fatal("in-flight slot still held after complete")
}
release()
}
func TestDiagnosticsChunkedUploadInitOverBundleLimit(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.MaxBundleBytes = 1024
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
rec := doChunked(t, router, http.MethodPost, "/uploads",
[]byte(`{"manifest":{"ok":true},"bundle_bytes":2048}`), accessClaims())
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
// Rejected init must not leave the limiter slot held.
release, acquired := handler.inflight.acquire(42)
if !acquired {
t.Fatal("in-flight slot held after rejected init")
}
release()
}
func TestDiagnosticsChunkedUploadInitDisabled(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.Status = diagnostics.StatusDisabled
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
rec := doChunked(t, router, http.MethodPost, "/uploads",
[]byte(`{"manifest":{"ok":true},"bundle_bytes":10}`), accessClaims())
assertDiagnosticsError(t, rec, http.StatusForbidden, "disabled")
}
func TestDiagnosticsChunkedUploadForeignSessionRejected(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
session := initChunkedUpload(t, router, 10, accessClaims())
other := &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), other)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign chunk status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, other)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign complete status = %d, want 404", rec.Code)
}
// A foreign abort reports success but must not destroy the session.
rec = doChunked(t, router, http.MethodDelete, "/uploads/"+session.UploadID, nil, other)
if rec.Code != http.StatusNoContent {
t.Fatalf("foreign abort status = %d, want 204", rec.Code)
}
rec = doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("owner chunk after foreign abort = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestDiagnosticsChunkedUploadIncompleteComplete(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)+512)
session := initChunkedUpload(t, router, len(bundle), accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
bundle[:diagnostics.UploadChunkBytes], accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk status = %d", rec.Code)
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusConflict {
t.Fatalf("incomplete complete status = %d, want 409; body=%s", rec.Code, rec.Body.String())
}
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsChunkedUploadReinitReplacesPreviousSession(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
first := initChunkedUpload(t, router, 10, accessClaims())
second := initChunkedUpload(t, router, 10, accessClaims())
if first.UploadID == second.UploadID {
t.Fatal("re-init returned the same session id")
}
// The replaced session is gone; the new one accepts chunks.
rec := doChunked(t, router, http.MethodPut, "/uploads/"+first.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusNotFound {
t.Fatalf("replaced-session chunk status = %d, want 404", rec.Code)
}
rec = doChunked(t, router, http.MethodPut, "/uploads/"+second.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("new-session chunk status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
rec = doChunked(t, router, http.MethodDelete, "/uploads/"+second.UploadID, nil, accessClaims())
if rec.Code != http.StatusNoContent {
t.Fatalf("abort status = %d, want 204", rec.Code)
}
}
func TestDiagnosticsChunkedCompleteBusyKeepsSessionRetryable(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
session := initChunkedUpload(t, router, 10, accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk status = %d", rec.Code)
}
// Occupy the user's ingest slot: complete must answer busy without
// consuming the spooled session.
release, acquired := handler.inflight.acquire(42)
if !acquired {
t.Fatal("could not occupy in-flight slot")
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
assertDiagnosticsError(t, rec, http.StatusServiceUnavailable, "busy")
release()
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusCreated {
t.Fatalf("retried complete status = %d; body=%s", rec.Code, rec.Body.String())
}
if service.ingestCalls != 1 {
t.Fatalf("ingest calls = %d, want 1", service.ingestCalls)
}
}
func TestDiagnosticsChunkedUploadOversizedChunkRejected(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)*2)
session := initChunkedUpload(t, router, len(bundle), accessClaims())
oversized := bytes.Repeat([]byte("x"), int(diagnostics.UploadChunkBytes)+1)
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
oversized, accessClaims())
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized chunk status = %d, want 400 or 413; body=%s", rec.Code, rec.Body.String())
}
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsChunkedUploadManifestTooLarge(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
manifest := `{"pad":"` + strings.Repeat("x", int(diagnostics.MaxManifestBytes)) + `"}`
rec := doChunked(t, router, http.MethodPost, "/uploads",
[]byte(`{"manifest":`+manifest+`,"bundle_bytes":10}`), accessClaims())
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
}
func TestDiagnosticsChunkedCompleteIngestErrorMapsLikeSingleShot(t *testing.T) {
service := newFakeDiagnosticsService()
service.ingestErr = diagnostics.ErrStaleConsent
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
session := initChunkedUpload(t, router, 10, accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk status = %d", rec.Code)
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
assertDiagnosticsError(t, rec, http.StatusBadRequest, "stale_consent")
// The failed session is consumed; a retry needs a fresh init and the slot
// must be free for it.
initChunkedUpload(t, router, 10, accessClaims())
}
func TestDiagnosticsChunkedUploadConcurrentInitsRespectSlotAtomically(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
// Fire concurrent inits for the same user. The reservation must admit at
// most one at a time — the losers get busy — so a single account can
// never fan out to multiple sessions and eat the global cap.
const attempts = 8
var wg sync.WaitGroup
codes := make([]int, attempts)
for i := 0; i < attempts; i++ {
wg.Add(1)
go func(slot int) {
defer wg.Done()
rec := doChunked(t, router, http.MethodPost, "/uploads",
[]byte(`{"manifest":{"ok":true},"bundle_bytes":10}`), accessClaims())
codes[slot] = rec.Code
}(i)
}
wg.Wait()
created := 0
for _, code := range codes {
switch code {
case http.StatusCreated:
created++
case http.StatusServiceUnavailable:
default:
t.Fatalf("unexpected init status %d", code)
}
}
if created < 1 {
t.Fatal("no init succeeded")
}
// However the race resolved, the user must end up with at most one live
// session and no leaked cap slots: after aborting it, a fresh init and
// full upload must succeed.
handler.chunkSessions.mu.Lock()
owned := len(handler.chunkSessions.owners)
reserved := len(handler.chunkSessions.reserved)
handler.chunkSessions.mu.Unlock()
if owned > 1 || reserved != 0 {
t.Fatalf("owners = %d (want ≤1), reserved = %d (want 0)", owned, reserved)
}
session := initChunkedUpload(t, router, 10, accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("post-race chunk status = %d; body=%s", rec.Code, rec.Body.String())
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusCreated {
t.Fatalf("post-race complete status = %d; body=%s", rec.Code, rec.Body.String())
}
}
func TestDiagnosticsChunkedCompleteTransientStatusErrorKeepsSession(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
session := initChunkedUpload(t, router, 10, accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk status = %d", rec.Code)
}
// A transient status failure must answer 500 WITHOUT destroying the fully
// uploaded spool; the retried complete succeeds with no re-upload.
service.setStatusErr(errFakeStatusUnavailable)
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
assertDiagnosticsError(t, rec, http.StatusInternalServerError, "internal_error")
service.setStatusErr(nil)
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusCreated {
t.Fatalf("retried complete status = %d; body=%s", rec.Code, rec.Body.String())
}
}
func TestDiagnosticsStatusAdvertisesUploadChunkBytes(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.UploadChunkBytes = diagnostics.UploadChunkBytes
handler := newChunkedTestHandler(t, service)
req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/status", nil)
req.Header.Set("Authorization", "Bearer token")
req = req.WithContext(apimw.SetClaims(req.Context(), accessClaims()))
rec := httptest.NewRecorder()
handler.HandleStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var payload map[string]any
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode status: %v", err)
}
got, ok := payload["upload_chunk_bytes"].(float64)
if !ok || int64(got) != diagnostics.UploadChunkBytes {
t.Fatalf("upload_chunk_bytes = %v, want %d", payload["upload_chunk_bytes"], diagnostics.UploadChunkBytes)
}
}
+25 -42
View File
@@ -704,32 +704,29 @@ func adminClaims() *auth.Claims {
}
type fakeDiagnosticsService struct {
mu sync.Mutex
status diagnostics.Status
statusErr error
statusCalls int
ingestCalls int
ingestErr error
lastManifest []byte
lastBundleBytes int64
started chan struct{}
block chan struct{}
listCalls int
listFilters diagnostics.ListFilters
listResult diagnostics.ListResult
listErr error
getReport *diagnostics.Report
getErr error
presignURL string
presignErr error
presignCalls int
presignExpiry time.Duration
effectiveTTL time.Duration
openCalls int
openData []byte
openErr error
deleteReport *diagnostics.Report
deleteErr error
mu sync.Mutex
status diagnostics.Status
statusCalls int
ingestCalls int
ingestErr error
started chan struct{}
block chan struct{}
listCalls int
listFilters diagnostics.ListFilters
listResult diagnostics.ListResult
listErr error
getReport *diagnostics.Report
getErr error
presignURL string
presignErr error
presignCalls int
presignExpiry time.Duration
effectiveTTL time.Duration
openCalls int
openData []byte
openErr error
deleteReport *diagnostics.Report
deleteErr error
}
func newFakeDiagnosticsService() *fakeDiagnosticsService {
@@ -750,22 +747,12 @@ func (f *fakeDiagnosticsService) Status(context.Context, int) (diagnostics.Statu
f.mu.Lock()
defer f.mu.Unlock()
f.statusCalls++
if f.statusErr != nil {
return diagnostics.Status{}, f.statusErr
}
return f.status, nil
}
func (f *fakeDiagnosticsService) setStatusErr(err error) {
f.mu.Lock()
f.statusErr = err
f.mu.Unlock()
}
func (f *fakeDiagnosticsService) Ingest(_ context.Context, _ int, _ *string, manifestJSON []byte, bundle io.Reader) (diagnostics.IngestResult, error) {
func (f *fakeDiagnosticsService) Ingest(_ context.Context, _ int, _ *string, _ []byte, bundle io.Reader) (diagnostics.IngestResult, error) {
f.mu.Lock()
f.ingestCalls++
f.lastManifest = append([]byte(nil), manifestJSON...)
started := f.started
block := f.block
err := f.ingestErr
@@ -780,13 +767,9 @@ func (f *fakeDiagnosticsService) Ingest(_ context.Context, _ int, _ *string, man
if err != nil {
return diagnostics.IngestResult{}, err
}
consumed, err := io.Copy(io.Discard, bundle)
if err != nil {
if _, err := io.ReadAll(bundle); err != nil {
return diagnostics.IngestResult{}, err
}
f.mu.Lock()
f.lastBundleBytes = consumed
f.mu.Unlock()
return diagnostics.IngestResult{
ReportID: "11111111-1111-1111-1111-111111111111",
ShortID: "SILO-ABCDEF123456",
@@ -1,66 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// eventsCapabilityResponse describes how a client may subscribe to the events
// websocket.
//
// Clients are expected to run a current build rather than negotiate down to an
// old server, so this is not a branch-on-capability contract. It is how a
// client tells the difference between "this server does not do that" and "the
// connection failed", which is what lets it say the deployment is out of date
// instead of failing opaquely: a server predating declared channels ignores
// ?channels=, answers required_action:"subscribe", and closes the connection
// after the grace period, which is indistinguishable from a broken socket.
type eventsCapabilityResponse struct {
SchemaVersion int `json:"schema_version"`
// SubscribeFrame reports the handshake: connect, then send a subscribe
// frame. Always true; named so a future removal is detectable rather than
// silent.
SubscribeFrame bool `json:"subscribe_frame"`
// DeclaredChannels reports that ?channels= is honored on connect, that such
// a connection is exempt from the subscribe grace period, and that its
// hello frame carries required_action:"none".
DeclaredChannels bool `json:"declared_channels"`
// SubscribeGracePeriodSeconds is how long a connection holding no
// subscription may stay silent before it is closed. 0 would mean no
// deadline.
SubscribeGracePeriodSeconds int `json:"subscribe_grace_period_seconds"`
// MaxRequestedChannels is the most channels one selection may name, on the
// URL or in a subscribe frame. Names past it are answered with a single
// too_many_channels rejection rather than one per name.
MaxRequestedChannels int `json:"max_requested_channels"`
// Channels is every channel a client may ask for on this server,
// independent of role — not every channel the server has: the plugins
// channel is host-to-plugin runtime dispatch and is granted to no role, so
// naming it here would advertise a request that can only be refused. What
// the caller may actually subscribe to arrives as available_channels in the
// hello frame, which is role-filtered.
Channels []evt.EventChannel `json:"channels"`
}
// HandleCapability reports the events websocket's subscription capabilities.
//
// Per the v1 rules, new functionality is feature-detected rather than inferred
// from a version. This follows the existing per-subsystem convention
// (/notifications/capability, /playback/capability, /downloads/capability).
//
// A client that finds declared_channels false is talking to a server older than
// its own expectations; the useful response is to tell the user to update the
// server, not to silently fall back.
func (h *EventsHandler) HandleCapability(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(eventsCapabilityResponse{
SchemaVersion: 1,
SubscribeFrame: true,
DeclaredChannels: true,
SubscribeGracePeriodSeconds: int(subscribeGracePeriod.Seconds()),
MaxRequestedChannels: maxRequestedChannels,
Channels: evt.ClientChannels,
})
}
@@ -1,80 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
evt "github.com/Silo-Server/silo-server/internal/events"
)
func TestEventsCapabilityReportsDeclaredChannelSupport(t *testing.T) {
handler := &EventsHandler{}
rec := httptest.NewRecorder()
handler.HandleCapability(rec, httptest.NewRequest(http.MethodGet, "/events/capability", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got eventsCapabilityResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding capability: %v (%s)", err, rec.Body.String())
}
if !got.DeclaredChannels {
t.Error("declared_channels = false; a client cannot detect ?channels= support")
}
if !got.SubscribeFrame {
t.Error("subscribe_frame = false; the handshake is still supported")
}
if got.SchemaVersion != 1 {
t.Errorf("schema_version = %d, want 1", got.SchemaVersion)
}
// The advertised grace period must be the one the handler actually
// enforces, or a client will size its handshake timeout against fiction.
if want := int(subscribeGracePeriod.Seconds()); got.SubscribeGracePeriodSeconds != want {
t.Errorf("subscribe_grace_period_seconds = %d, want %d", got.SubscribeGracePeriodSeconds, want)
}
if got.MaxRequestedChannels != maxRequestedChannels {
t.Errorf("max_requested_channels = %d, want %d", got.MaxRequestedChannels, maxRequestedChannels)
}
if len(got.Channels) != len(evt.ClientChannels) {
t.Errorf("channels = %v, want all %d client channels", got.Channels, len(evt.ClientChannels))
}
}
// TestEventsCapabilityAdvertisesOnlySubscribableChannels is the point of
// publishing the list at all: a client that requests everything the endpoint
// names must not be refused any of it. The plugins channel is the case — it is
// in evt.AllChannels but granted to no role, admin included, so advertising it
// would send a client to a request that can only fail.
func TestEventsCapabilityAdvertisesOnlySubscribableChannels(t *testing.T) {
handler := &EventsHandler{}
rec := httptest.NewRecorder()
handler.HandleCapability(rec, httptest.NewRequest(http.MethodGet, "/events/capability", nil))
var got eventsCapabilityResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding capability: %v (%s)", err, rec.Body.String())
}
// An admin, on a profile-bound connection, is the most permissive caller
// there is. Every advertised channel must resolve for them.
_, accepted, rejected := resolveChannelSelection(
got.Channels, allowedChannelsForRole("admin"), "profile-1")
if len(rejected) != 0 {
t.Errorf("capability advertises channels an admin cannot subscribe to: %v", rejected)
}
if len(accepted) != len(got.Channels) {
t.Errorf("accepted %d of %d advertised channels", len(accepted), len(got.Channels))
}
}
+65 -236
View File
@@ -5,9 +5,6 @@ import (
"encoding/json"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/adminjob"
@@ -33,41 +30,6 @@ type taskInfoLister interface {
const maxRealtimeScanSnapshotRuns = 500
// subscribeGracePeriod bounds how long a connection that is not subscribed to
// anything may stay silent before it is closed. It exists to distinguish a
// client mid-handshake from one that connected and stalled; a connection that
// arrived with a usable ?channels= selection is never put on this clock.
const subscribeGracePeriod = 5 * time.Second
// maxRequestedChannels bounds how many distinct channels one selection may
// name. Every refusal is echoed back with its channel name, so without a cap a
// selection of many unknown names is an amplifier: the request is bounded by
// the header/frame limit, the answer was not. Any real client names a subset of
// the ten client channels; the slack is only so a caller learns it overshot
// rather than silently losing channels.
const maxRequestedChannels = 32
// maxChannelNameLength bounds the channel name echoed in a rejection. A name
// longer than any real channel is garbage by definition, and quoting it back in
// full is the second half of the same amplification.
const maxChannelNameLength = 64
// maxEventsFrameBytes bounds an inbound frame on this socket. The only message
// it accepts is a subscribe frame naming at most maxRequestedChannels channels,
// which is orders of magnitude smaller; the limit exists so a client cannot
// make the server buffer an arbitrarily large frame before it is rejected.
const maxEventsFrameBytes = 64 * 1024
// required_action values in the hello frame: whether the client still owes a
// subscribe frame, or already holds a subscription it declared on the URL.
const (
requiredActionSubscribe = "subscribe"
requiredActionNone = "none"
)
// frameTypeSubscribed is the acknowledgement both selection paths answer with.
const frameTypeSubscribed = "subscribed"
type activeScanLister interface {
ListActiveSnapshot(ctx context.Context, limit int) ([]evt.ScanRun, error)
}
@@ -165,7 +127,6 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
}
defer conn.Close()
configureWebSocket(conn)
conn.SetReadLimit(maxEventsFrameBytes)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
@@ -177,52 +138,17 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
})
allowedChannels := allowedChannelsForRole(claims.Role)
// A connection may declare its channels on the URL instead of sending a
// subscribe frame. That is all an observer needs — subscribe is the only
// inbound message this endpoint accepts — so declaring up front lets a
// read-only client skip the handshake, the grace period, and the write
// half of the socket entirely.
declaredChannels, declared := parseDeclaredChannels(r.URL.Query())
// Resolved before the hello frame so required_action can report what the
// connection actually owes. Resolution is pure — no query, no I/O — so
// nothing here can stall ahead of the reader that starts below; only the
// snapshots the accepted channels produce can, and those still run after
// it.
declaredSubs := make(map[evt.EventChannel]struct{})
var declaredAccepted []evt.EventChannel
var declaredRejected []evt.EventsRejectedChannel
if declared {
declaredSubs, declaredAccepted, declaredRejected =
resolveChannelSelection(declaredChannels, allowedChannels, boundProfileID)
}
// A declaration that yielded no subscription discharges nothing: the client
// still owes a subscribe frame, and saying "none" would send it to wait
// silently for events on a connection due to be closed in five seconds.
requiredAction := requiredActionSubscribe
if len(declaredSubs) > 0 {
requiredAction = requiredActionNone
}
connectionID := ulid.Make().String()
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
Type: "hello",
SchemaVersion: 1,
ConnectionID: connectionID,
AvailableChannels: allowedChannels,
RequiredAction: requiredAction,
RequiredAction: "subscribe",
}); err != nil {
return
}
// The reader starts before any snapshot query runs. configureWebSocket
// installs an absolute read deadline that only pongs extend, and gorilla
// processes pongs solely inside ReadMessage — so a snapshot slow enough to
// outlast the deadline (a loaded jobs/sessions/scans query) would kill an
// otherwise healthy connection the moment reading began. The handshake path
// is safe for free, building its snapshots downstream of an active reader;
// the declared path has to arrange that ordering deliberately.
readMessages := make(chan []byte, 8)
readDone := make(chan struct{})
go func() {
@@ -240,40 +166,11 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
}
}()
subscriptions := declaredSubs
if declared {
// The same subscribed frame the handshake produces, so a client sees
// one acknowledgement shape however it selected its channels — and
// still learns which of its requests were refused, and why.
if err := writeWebSocketJSON(conn, evt.EventsSubscribedMessage{
Type: frameTypeSubscribed,
Channels: declaredAccepted,
Rejected: declaredRejected,
}); err != nil {
return
}
for _, channel := range declaredAccepted {
if err := h.writeSnapshotFrame(conn, r, claims, boundProfileID, channel); err != nil {
return
}
}
}
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
// The clock is disarmed by holding a subscription, not by having declared
// one. A declaration that resolved to nothing — ?channels= with no names,
// or a selection every entry of which was refused — leaves the connection
// in exactly the state the grace period exists to reap: subscribed to
// nothing, with no reason to expect it to ever receive a frame, while still
// holding a hub subscriber, two goroutines, and an envelope channel that
// every published event fans into. Such a client still owes a subscribe
// frame, and its rejected list told it why. A nil channel blocks forever,
// which is exactly the disarmed case.
var deadlineC <-chan time.Time
if len(subscriptions) == 0 {
deadline := time.NewTimer(subscribeGracePeriod)
defer deadline.Stop()
deadlineC = deadline.C
}
subscriptions := make(map[evt.EventChannel]struct{})
subscribedOnce := false
for {
select {
@@ -281,8 +178,11 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
case <-readDone:
return
case <-deadlineC:
writeWebSocketError(conn, "bad_request", "subscribe is required within "+subscribeGracePeriod.String())
case <-deadline.C:
if subscribedOnce {
continue
}
writeWebSocketError(conn, "bad_request", "subscribe is required within 5 seconds")
_ = writeWebSocketControl(
conn,
websocket.CloseMessage,
@@ -297,10 +197,13 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
if !handled {
continue
}
// Obligation discharged: detach the timer so a later tick cannot
// close a connection that has since subscribed. The timer itself
// is stopped by the deferred Stop above.
deadlineC = nil
subscribedOnce = true
if !deadline.Stop() {
select {
case <-deadline.C:
default:
}
}
subscriptions = nextSubs
case env, ok := <-eventsCh:
if !ok {
@@ -360,10 +263,56 @@ func (h *EventsHandler) handleEventsClientMessage(
return nil, false, false
}
nextSubs, accepted, rejected := resolveChannelSelection(message.Channels, allowed, boundProfileID)
allowedSet := make(map[evt.EventChannel]struct{}, len(allowed))
for _, channel := range allowed {
allowedSet[channel] = struct{}{}
}
validSet := make(map[evt.EventChannel]struct{}, len(evt.AllChannels))
for _, channel := range evt.AllChannels {
validSet[channel] = struct{}{}
}
nextSubs := make(map[evt.EventChannel]struct{}, len(message.Channels))
accepted := make([]evt.EventChannel, 0, len(message.Channels))
rejected := make([]evt.EventsRejectedChannel, 0)
for _, channel := range message.Channels {
if _, ok := validSet[channel]; !ok {
writeWebSocketError(conn, "bad_request", "Invalid channel")
_ = writeWebSocketControl(
conn,
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "invalid channel"),
)
return nil, false, false
}
if _, ok := allowedSet[channel]; !ok {
rejected = append(rejected, evt.EventsRejectedChannel{
Channel: channel,
Code: "forbidden",
Message: "Admin access required",
})
continue
}
// The notifications channel is profile-scoped: it requires a
// connection bound to a profile via a websocket ticket.
if channel == evt.ChannelNotifications && boundProfileID == "" {
rejected = append(rejected, evt.EventsRejectedChannel{
Channel: channel,
Code: "profile_required",
Message: "A profile-bound websocket ticket is required",
})
continue
}
if _, seen := nextSubs[channel]; seen {
continue
}
nextSubs[channel] = struct{}{}
accepted = append(accepted, channel)
}
if err := writeWebSocketJSON(conn, evt.EventsSubscribedMessage{
Type: frameTypeSubscribed,
Type: "subscribed",
RequestID: message.RequestID,
Channels: accepted,
Rejected: rejected,
@@ -380,131 +329,11 @@ func (h *EventsHandler) handleEventsClientMessage(
return nextSubs, true, true
}
// resolveChannelSelection decides which of the requested channels a connection
// may subscribe to. It is the single place that answers that question, shared
// by the URL-declared path and the subscribe frame so the two cannot drift.
//
// Every rejection is reported rather than fatal: an unknown, forbidden, or
// profile-requiring channel costs the caller that channel and nothing else.
// Closing the connection instead would take down every other channel it holds
// over one bad name, and a client cannot always know which channels its role
// allows before it asks.
//
// Because refusals are answered instead of closing the connection, the answer
// has to be bounded independently of the request: at most maxRequestedChannels
// distinct names are considered, and an overrun is reported once rather than
// per excess name.
func resolveChannelSelection(
requested []evt.EventChannel,
allowed []evt.EventChannel,
boundProfileID string,
) (map[evt.EventChannel]struct{}, []evt.EventChannel, []evt.EventsRejectedChannel) {
allowedSet := make(map[evt.EventChannel]struct{}, len(allowed))
for _, channel := range allowed {
allowedSet[channel] = struct{}{}
}
validSet := make(map[evt.EventChannel]struct{}, len(evt.ClientChannels))
for _, channel := range evt.ClientChannels {
validSet[channel] = struct{}{}
}
capacity := min(len(requested), maxRequestedChannels)
subs := make(map[evt.EventChannel]struct{}, capacity)
accepted := make([]evt.EventChannel, 0, capacity)
rejected := make([]evt.EventsRejectedChannel, 0, capacity)
// A repeated channel is answered once whether it was accepted or refused.
// Only the accepted side deduplicated before this change, so asking twice
// for one forbidden channel produced two identical rejections.
seen := make(map[evt.EventChannel]struct{}, capacity)
reject := func(channel evt.EventChannel, code, message string) {
// A garbage name is echoed back only far enough to be recognizable.
if len(channel) > maxChannelNameLength {
channel = channel[:maxChannelNameLength]
}
rejected = append(rejected, evt.EventsRejectedChannel{
Channel: channel,
Code: code,
Message: message,
})
}
for _, channel := range requested {
if _, dup := seen[channel]; dup {
continue
}
if len(seen) >= maxRequestedChannels {
// One entry for the whole overrun, not one per name: the point of
// the cap is that the response cannot grow with the request.
reject("", "too_many_channels",
"At most "+strconv.Itoa(maxRequestedChannels)+" channels may be requested at once")
break
}
seen[channel] = struct{}{}
switch {
case !contains(validSet, channel):
reject(channel, "unknown_channel", "Unknown channel")
case !contains(allowedSet, channel):
// Deliberately not "admin access required": the caller's role is
// the usual reason a channel is refused but not the only one, and a
// remedy that does not exist reads as a bug in the client's own
// permissions rather than a channel it was never going to get.
reject(channel, "forbidden", "This channel is not available to this connection")
// The notifications channel is profile-scoped: it requires a
// connection bound to a profile via a websocket ticket.
case channel == evt.ChannelNotifications && boundProfileID == "":
reject(channel, "profile_required", "A profile-bound websocket ticket is required")
default:
subs[channel] = struct{}{}
accepted = append(accepted, channel)
}
}
return subs, accepted, rejected
}
func contains(set map[evt.EventChannel]struct{}, channel evt.EventChannel) bool {
_, ok := set[channel]
return ok
}
// parseDeclaredChannels reads the optional ?channels= selection a connection
// may declare on the URL, letting a read-only observer skip the subscribe
// handshake entirely. An absent parameter returns ok=false, which keeps the
// handshake (and its grace period) in force; an empty or all-blank value is a
// deliberate declaration of nothing, so it returns ok=true and no channels.
//
// Every occurrence of the parameter is read, not just the first. Repeating a
// query parameter is as natural a spelling as a comma-separated list, and
// honoring one and silently discarding the rest loses channels with no
// diagnostic — the connection would come up subscribed to less than it asked
// for and report nothing rejected.
func parseDeclaredChannels(query url.Values) ([]evt.EventChannel, bool) {
values, ok := query["channels"]
if !ok {
return nil, false
}
channels := make([]evt.EventChannel, 0, 4)
for _, value := range values {
for _, raw := range strings.Split(value, ",") {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
channels = append(channels, evt.EventChannel(name))
}
}
return channels, true
}
func allowedChannelsForRole(role string) []evt.EventChannel {
channels := []evt.EventChannel{
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelUserState,
evt.ChannelUserSettings,
evt.ChannelNotifications,
}
if role == "admin" {
@@ -1,638 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
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/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// eventsWSTestConn dials the events websocket against a handler authenticated
// as the given claims, and returns a frame reader. These tests go through the
// real socket rather than calling the handler directly because the behavior
// under test — what a connection is subscribed to before it has said anything,
// and whether it survives the grace period — only exists in the connection
// loop.
func eventsWSTestConn(t *testing.T, hub *evt.Hub, claims *auth.Claims, query string) (
*websocket.Conn,
func(wantType string) map[string]json.RawMessage,
) {
t.Helper()
return eventsWSTestConnWithHandler(t, &EventsHandler{hub: hub}, claims, query)
}
func eventsWSTestConnWithHandler(
t *testing.T,
handler *EventsHandler,
claims *auth.Claims,
query string,
) (*websocket.Conn, func(wantType string) map[string]json.RawMessage) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), claims)
handler.HandleWebSocket(w, r.WithContext(ctx))
}))
t.Cleanup(server.Close)
conn, resp, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+query, nil)
if err != nil {
t.Fatalf("dialing events websocket: %v", err)
}
if resp != nil && resp.Body != nil {
t.Cleanup(func() { _ = resp.Body.Close() })
}
t.Cleanup(func() { _ = conn.Close() })
readFrame := func(wantType string) map[string]json.RawMessage {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("reading %s frame: %v", wantType, err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"`+wantType+`"` {
t.Fatalf("frame type = %s, want %q (frame: %s)", frame["type"], wantType, data)
}
return frame
}
return conn, readFrame
}
// TestEventsWebSocketDeclaredChannelsSkipHandshake is the point of the feature:
// a connection that named its channels on the URL receives events without ever
// writing to the socket.
func TestEventsWebSocketDeclaredChannelsSkipHandshake(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=user_settings")
hello := readFrame("hello")
if string(hello["required_action"]) != `"none"` {
t.Errorf("required_action = %s, want \"none\"", hello["required_action"])
}
subscribed := readFrame("subscribed")
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Fatalf("declared channel was not accepted: %s", subscribed["channels"])
}
// Accepted channels hydrate with a snapshot, exactly as the handshake does.
if snapshot := readFrame("snapshot"); string(snapshot["channel"]) != `"user_settings"` {
t.Fatalf("snapshot channel = %s, want user_settings", snapshot["channel"])
}
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if event := readFrame("event"); string(event["channel"]) != `"user_settings"` {
t.Errorf("event channel = %s, want user_settings", event["channel"])
}
}
// TestEventsWebSocketDeclaredChannelsSurviveGracePeriod guards the core promise
// of the change: an observer that connects and never speaks stays connected.
// Before this, it was closed with a policy violation after five seconds.
func TestEventsWebSocketDeclaredChannelsSurviveGracePeriod(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=user_settings")
readFrame("hello")
readFrame("subscribed")
readFrame("snapshot")
// Stay silent well past the deadline that would have closed a
// handshake-style connection, then confirm the socket still delivers.
time.Sleep(subscribeGracePeriod + time.Second)
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("connection did not survive the grace period: %v", err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"event"` {
t.Fatalf("frame type = %s, want \"event\" (frame: %s)", frame["type"], data)
}
}
// TestEventsWebSocketEmptyDeclarationStillClosed pins what disarms the grace
// period: holding a subscription, not having spelled ?channels=. A declaration
// that resolved to nothing leaves the connection in the exact state the clock
// exists to reap — no subscriptions, no reason to expect a frame, but still a
// hub subscriber, two goroutines, and an envelope channel every published
// event fans into.
func TestEventsWebSocketEmptyDeclarationStillClosed(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
tests := []struct {
name string
query string
}{
{name: "declares no channels", query: "?channels="},
// Every name refused: a non-admin naming only an admin channel is
// subscribed to nothing, exactly as if it had named nothing.
{name: "every declared channel refused", query: "?channels=sessions"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, tt.query)
hello := readFrame("hello")
// The obligation is real, so the hello frame has to say so rather
// than sending the client off to wait silently on a doomed socket.
if string(hello["required_action"]) != `"subscribe"` {
t.Errorf("required_action = %s, want \"subscribe\"", hello["required_action"])
}
readFrame("subscribed")
if err := conn.SetReadDeadline(time.Now().Add(subscribeGracePeriod + 5*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
// The error frame, then the close.
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("reading error frame: %v", err)
}
if _, _, err := conn.ReadMessage(); !websocket.IsCloseError(err, websocket.ClosePolicyViolation) {
t.Fatalf("close error = %v, want policy violation", err)
}
})
}
}
// TestEventsWebSocketPartialDeclarationSurvives is the boundary case on the
// other side: one accepted channel among refusals is a live subscription, so
// the connection is not on the clock.
func TestEventsWebSocketPartialDeclarationSurvives(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=sessions,user_settings")
hello := readFrame("hello")
if string(hello["required_action"]) != `"none"` {
t.Errorf("required_action = %s, want \"none\"", hello["required_action"])
}
readFrame("subscribed")
readFrame("snapshot")
time.Sleep(subscribeGracePeriod + time.Second)
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("a partially accepted declaration did not survive the grace period: %v", err)
}
}
// TestEventsWebSocketRepeatedChannelsParameter covers the other natural
// spelling of a selection. Honoring only the first occurrence dropped the rest
// with an empty rejected array — the connection came up subscribed to less than
// it asked for and reported nothing wrong.
func TestEventsWebSocketRepeatedChannelsParameter(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"},
"?channels=catalog&channels=user_settings")
readFrame("hello")
subscribed := readFrame("subscribed")
for _, want := range []string{`"catalog"`, `"user_settings"`} {
if !strings.Contains(string(subscribed["channels"]), want) {
t.Errorf("channel %s was dropped: %s", want, subscribed["channels"])
}
}
}
// TestEventsWebSocketSilentConnectionStillClosed pins the other half: the grace
// period still applies to a connection that declared nothing, so the URL path
// relaxes the rule rather than removing it.
func TestEventsWebSocketSilentConnectionStillClosed(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
hello := readFrame("hello")
if string(hello["required_action"]) != `"subscribe"` {
t.Errorf("required_action = %s, want \"subscribe\"", hello["required_action"])
}
if err := conn.SetReadDeadline(time.Now().Add(subscribeGracePeriod + 5*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
// The error frame, then the close.
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("reading error frame: %v", err)
}
_, _, err := conn.ReadMessage()
if err == nil {
t.Fatal("silent connection was not closed after the grace period")
}
if !websocket.IsCloseError(err, websocket.ClosePolicyViolation) {
t.Fatalf("close error = %v, want policy violation", err)
}
}
// TestEventsWebSocketDeclaredChannelsCannotEscalate is the authorization
// guarantee: naming an admin-only channel on the URL must not grant it, and
// must not grant the events published to it either.
func TestEventsWebSocketDeclaredChannelsCannotEscalate(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=sessions,user_settings")
readFrame("hello")
subscribed := readFrame("subscribed")
if strings.Contains(string(subscribed["channels"]), `"sessions"`) {
t.Fatalf("non-admin was granted the sessions channel: %s", subscribed["channels"])
}
if !strings.Contains(string(subscribed["rejected"]), `"forbidden"`) {
t.Errorf("sessions was not reported as forbidden: %s", subscribed["rejected"])
}
// The permitted channel in the same request still landed.
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Errorf("a forbidden channel denied the rest of the request: %s", subscribed["channels"])
}
readFrame("snapshot") // user_settings
// An admin-only event on the refused channel must not be delivered. Publish
// it first, then a permitted event; receiving the second without the first
// proves the first was filtered rather than merely slow.
if err := hub.PublishJSON(context.Background(), evt.ChannelSessions, "sessions.replaced", nil,
evt.PublishOptions{AdminOnly: true}); err != nil {
t.Fatalf("publishing admin-only event: %v", err)
}
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
event := readFrame("event")
if string(event["channel"]) != `"user_settings"` {
t.Fatalf("received an event on a channel this connection was refused: %s", event["channel"])
}
}
// TestEventsWebSocketUnknownChannelDoesNotCloseConnection covers the third
// change: an unrecognized channel name used to close the socket outright,
// taking down every other channel the client held over one bad name.
func TestEventsWebSocketUnknownChannelDoesNotCloseConnection(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
readFrame("hello")
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
Type: "subscribe",
RequestID: "r1",
Channels: []evt.EventChannel{"not_a_channel", evt.ChannelUserSettings},
}); err != nil {
t.Fatalf("sending subscribe: %v", err)
}
subscribed := readFrame("subscribed")
if !strings.Contains(string(subscribed["rejected"]), `"unknown_channel"`) {
t.Errorf("unknown channel was not reported as such: %s", subscribed["rejected"])
}
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Fatalf("an unknown channel denied the valid one alongside it: %s", subscribed["channels"])
}
// The connection is still usable.
readFrame("snapshot")
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if event := readFrame("event"); string(event["channel"]) != `"user_settings"` {
t.Errorf("event channel = %s, want user_settings", event["channel"])
}
}
// TestEventsWebSocketRejectsOversizeFrame covers the one case that is still
// fatal, and has to be: a frame is buffered whole before its type can be read,
// so an oversize frame cannot be answered with a rejection the way a bad
// channel name can — refusing it politely would mean first doing the thing the
// limit exists to prevent.
func TestEventsWebSocketRejectsOversizeFrame(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
readFrame("hello")
oversize := `{"type":"subscribe","channels":["` +
strings.Repeat("x", maxEventsFrameBytes*2) + `"]}`
// The write itself may fail once the server has already torn the connection
// down, which is the same outcome; only accepting the frame is a failure.
if err := conn.WriteMessage(websocket.TextMessage, []byte(oversize)); err != nil {
return
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
if _, _, err := conn.ReadMessage(); err == nil {
t.Fatal("a frame past the read limit was accepted")
}
}
// slowTaskLister stalls the tasks snapshot long enough to outlast the read
// deadline configureWebSocket installs at connect.
type slowTaskLister struct{ delay time.Duration }
func (s slowTaskLister) ListTasks(bool) []taskmanager.TaskInfo {
time.Sleep(s.delay)
return []taskmanager.TaskInfo{}
}
// TestEventsWebSocketDeclaredChannelsSurviveSlowSnapshot pins the ordering the
// declared path depends on. configureWebSocket sets an absolute read deadline
// that only pongs extend, and gorilla processes pongs solely inside
// ReadMessage — so if snapshot queries ran before the reader goroutine started,
// a snapshot slower than the deadline would kill a healthy connection the
// instant reading began. The handshake path gets this for free by building
// snapshots downstream of an active reader; the declared path arranges it
// deliberately.
func TestEventsWebSocketDeclaredChannelsSurviveSlowSnapshot(t *testing.T) {
if testing.Short() {
t.Skip("stalls a snapshot past the websocket read deadline in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
handler := &EventsHandler{
hub: hub,
tasks: slowTaskLister{delay: wsPingInterval + wsPongTimeout + 2*time.Second},
}
conn, readFrame := eventsWSTestConnWithHandler(t, handler,
&auth.Claims{UserID: 1, Role: "admin"}, "?channels=tasks")
readFrame("hello")
readFrame("subscribed")
// The snapshot arrives late by design; allow for the stall plus slack.
if err := conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout + 15*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("connection died during a slow snapshot: %v", err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"snapshot"` {
t.Fatalf("frame type = %s, want \"snapshot\" (frame: %s)", frame["type"], data)
}
// And the connection is still live afterwards.
if err := hub.PublishJSON(context.Background(), evt.ChannelTasks, "tasks.changed",
map[string]string{"id": "t1"}, evt.PublishOptions{}); err != nil {
t.Fatalf("publishing: %v", err)
}
if event := readFrame("event"); string(event["channel"]) != `"tasks"` {
t.Errorf("event channel = %s, want tasks", event["channel"])
}
}
func TestParseDeclaredChannels(t *testing.T) {
tests := []struct {
name string
query string
want []evt.EventChannel
declared bool
}{
{
name: "absent parameter keeps the handshake",
query: "",
want: nil,
declared: false,
},
{
name: "empty value declares nothing, but still declares",
query: "channels=",
want: []evt.EventChannel{},
declared: true,
},
{
name: "whitespace and empty entries are dropped",
query: "channels=catalog,%20,,user_state%20",
want: []evt.EventChannel{evt.ChannelCatalog, evt.ChannelUserState},
declared: true,
},
{
// Repeating the parameter is as natural a spelling as one comma
// list; reading only the first occurrence lost the rest silently.
name: "every occurrence of the parameter is read",
query: "channels=catalog&channels=user_state,user_settings",
want: []evt.EventChannel{evt.ChannelCatalog, evt.ChannelUserState, evt.ChannelUserSettings},
declared: true,
},
{
name: "a repeated parameter with only blank values still declares",
query: "channels=&channels=%20",
want: []evt.EventChannel{},
declared: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query, err := url.ParseQuery(tt.query)
if err != nil {
t.Fatalf("parsing query: %v", err)
}
got, declared := parseDeclaredChannels(query)
if declared != tt.declared {
t.Fatalf("declared = %v, want %v", declared, tt.declared)
}
if len(got) != len(tt.want) {
t.Fatalf("channels = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("channels = %v, want %v", got, tt.want)
}
}
})
}
}
func TestResolveChannelSelectionDeniesByDefault(t *testing.T) {
allowed := allowedChannelsForRole("user")
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelSessions, "bogus", evt.ChannelNotifications},
allowed,
"", // unbound: notifications requires a profile-bound ticket
)
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("nothing should have been accepted: subs=%v accepted=%v", subs, accepted)
}
codes := make(map[string]evt.EventChannel, len(rejected))
for _, r := range rejected {
codes[r.Code] = r.Channel
}
if codes["forbidden"] != evt.ChannelSessions {
t.Errorf("sessions not rejected as forbidden: %v", rejected)
}
if codes["unknown_channel"] != "bogus" {
t.Errorf("bogus not rejected as unknown: %v", rejected)
}
if codes["profile_required"] != evt.ChannelNotifications {
t.Errorf("notifications not rejected as profile_required: %v", rejected)
}
}
func TestResolveChannelSelectionDeduplicates(t *testing.T) {
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelJobs, evt.ChannelJobs},
allowedChannelsForRole("admin"),
"",
)
if len(subs) != 1 || len(accepted) != 1 {
t.Fatalf("duplicate channel was not collapsed: subs=%v accepted=%v", subs, accepted)
}
if len(rejected) != 0 {
t.Fatalf("unexpected rejections: %v", rejected)
}
}
// TestResolveChannelSelectionDeduplicatesRejections covers the other half of
// dedup: a channel asked for twice is answered once whether it was accepted or
// refused. Only the accepted side deduplicated before.
func TestResolveChannelSelectionDeduplicatesRejections(t *testing.T) {
_, _, rejected := resolveChannelSelection(
[]evt.EventChannel{
evt.ChannelSessions, evt.ChannelSessions, // forbidden for a user
"bogus", "bogus", // unknown
},
allowedChannelsForRole("user"),
"",
)
if len(rejected) != 2 {
t.Fatalf("rejected = %v, want one entry per distinct channel", rejected)
}
}
// TestResolveChannelSelectionBoundsTheAnswer is the amplification guard.
// Refusals quote the name they refuse, so once an unknown channel stopped
// closing the connection, the response grew with the request: a large selection
// of distinct garbage names produced a far larger subscribed frame, buffered
// server-side. The answer has to be bounded independently of the request.
func TestResolveChannelSelectionBoundsTheAnswer(t *testing.T) {
requested := make([]evt.EventChannel, 0, 5000)
for i := range 5000 {
requested = append(requested, evt.EventChannel("bogus-"+strconv.Itoa(i)))
}
subs, accepted, rejected := resolveChannelSelection(requested, allowedChannelsForRole("user"), "")
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("garbage names were accepted: subs=%v accepted=%v", subs, accepted)
}
// Every considered name is refused, plus exactly one entry for the overrun.
if len(rejected) != maxRequestedChannels+1 {
t.Fatalf("rejected %d entries, want %d", len(rejected), maxRequestedChannels+1)
}
overrun := rejected[len(rejected)-1]
if overrun.Code != "too_many_channels" {
t.Errorf("last rejection code = %q, want too_many_channels", overrun.Code)
}
// The response must not scale with the request, whatever the constants are.
encoded, err := json.Marshal(evt.EventsSubscribedMessage{
Type: "subscribed",
Channels: accepted,
Rejected: rejected,
})
if err != nil {
t.Fatalf("encoding subscribed frame: %v", err)
}
if len(encoded) > 8*1024 {
t.Errorf("subscribed frame is %d bytes for a garbage selection", len(encoded))
}
}
// TestResolveChannelSelectionTruncatesLongNames covers the per-name half of the
// same concern: one enormous name is as good an amplifier as many small ones.
func TestResolveChannelSelectionTruncatesLongNames(t *testing.T) {
long := evt.EventChannel(strings.Repeat("x", 4096))
_, _, rejected := resolveChannelSelection(
[]evt.EventChannel{long}, allowedChannelsForRole("user"), "")
if len(rejected) != 1 {
t.Fatalf("rejected = %v, want one entry", rejected)
}
if len(rejected[0].Channel) != maxChannelNameLength {
t.Errorf("echoed name is %d bytes, want it truncated to %d",
len(rejected[0].Channel), maxChannelNameLength)
}
}
// TestResolveChannelSelectionRefusesPluginsChannel pins that the host-to-plugin
// dispatch channel is not reachable from a client connection, for any role.
func TestResolveChannelSelectionRefusesPluginsChannel(t *testing.T) {
for _, role := range []string{"user", "admin"} {
t.Run(role, func(t *testing.T) {
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelPlugins}, allowedChannelsForRole(role), "profile-1")
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("%s was granted the plugins channel: %v", role, accepted)
}
if len(rejected) != 1 || rejected[0].Code != "unknown_channel" {
t.Errorf("rejected = %v, want a single unknown_channel entry", rejected)
}
})
}
}
@@ -1,106 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
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/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// TestEventsWebSocketDeliversUserSettingsToNonAdmins goes through the real
// websocket rather than subscribing on the Hub directly, because that is the
// only place the channel's authorization lives: dropping ChannelUserSettings
// from allowedChannelsForRole answers the subscribe with {code:"forbidden"},
// and dropping it from evt.AllChannels closes the connection as an invalid
// channel — either way the server would keep publishing change events no
// client could ever receive, while every Hub-level test stayed green.
func TestEventsWebSocketDeliversUserSettingsToNonAdmins(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
handler := &EventsHandler{hub: hub}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The router authenticates before the handler runs; a plain (non-admin)
// user is the role whose devices must hear their own settings change.
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user"})
handler.HandleWebSocket(w, r.WithContext(ctx))
}))
defer server.Close()
conn, resp, err := websocket.DefaultDialer.Dial(
"ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatalf("dialing events websocket: %v", err)
}
if resp != nil && resp.Body != nil {
defer func() { _ = resp.Body.Close() }()
}
defer func() { _ = conn.Close() }()
readFrame := func(wantType string) map[string]json.RawMessage {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("reading %s frame: %v", wantType, err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"`+wantType+`"` {
t.Fatalf("frame type = %s, want %q (frame: %s)", frame["type"], wantType, data)
}
return frame
}
hello := readFrame("hello")
if !strings.Contains(string(hello["available_channels"]), `"user_settings"`) {
t.Fatalf("hello does not offer user_settings: %s", hello["available_channels"])
}
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
Type: "subscribe",
RequestID: "r1",
Channels: []evt.EventChannel{evt.ChannelUserSettings},
}); err != nil {
t.Fatalf("sending subscribe: %v", err)
}
subscribed := readFrame("subscribed")
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Fatalf("subscribe was not accepted: %s", subscribed["channels"])
}
if rejected, present := subscribed["rejected"]; present && string(rejected) != "null" && string(rejected) != "[]" {
t.Fatalf("subscribe was rejected: %s", rejected)
}
// The accepted subscription hydrates with a snapshot frame first.
snapshot := readFrame("snapshot")
if string(snapshot["channel"]) != `"user_settings"` {
t.Fatalf("snapshot channel = %s, want user_settings", snapshot["channel"])
}
// A change event addressed to this user must reach the connection.
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
event := readFrame("event")
if string(event["channel"]) != `"user_settings"` {
t.Errorf("event channel = %s, want user_settings", event["channel"])
}
if string(event["event"]) != `"`+userSettingsChangedEvent+`"` {
t.Errorf("event = %s, want %q", event["event"], userSettingsChangedEvent)
}
}
+3 -10
View File
@@ -155,7 +155,7 @@ func (h *PersonalDataHandler) HandleListFavorites(w http.ResponseWriter, r *http
return
}
writeJSON(w, http.StatusOK, itemsListResponse{Items: items, HasMore: len(favorites) == limit})
writeJSON(w, http.StatusOK, itemsListResponse{Items: items})
}
// HandleCheckFavorite handles GET /favorites/{item_id}.
@@ -304,9 +304,6 @@ func (h *PersonalDataHandler) HandleListWatchlist(w http.ResponseWriter, r *http
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list watchlist")
return
}
// Capture has_more from the raw store page, before hidden-series filtering
// shrinks it — a full page that filters down still has more to fetch.
watchlistHasMore := len(entries) == limit
entries, err = h.filterHiddenWatchlistSeries(r.Context(), store, profileID, entries)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to filter watchlist")
@@ -319,7 +316,7 @@ func (h *PersonalDataHandler) HandleListWatchlist(w http.ResponseWriter, r *http
return
}
writeJSON(w, http.StatusOK, itemsListResponse{Items: items, HasMore: watchlistHasMore})
writeJSON(w, http.StatusOK, itemsListResponse{Items: items})
}
// filterHiddenWatchlistSeries drops fully-watched series from a watchlist page.
@@ -482,7 +479,7 @@ func (h *PersonalDataHandler) HandleListHistory(w http.ResponseWriter, r *http.R
return
}
writeJSON(w, http.StatusOK, itemsListResponse{Items: items, HasMore: len(entries) == limit})
writeJSON(w, http.StatusOK, itemsListResponse{Items: items})
}
// HandleRemoveHistory handles POST /history/remove.
@@ -555,10 +552,6 @@ func (h *PersonalDataHandler) HandleRemoveHistory(w http.ResponseWriter, r *http
// itemsListResponse wraps a list of items for favorites/watchlist responses.
type itemsListResponse struct {
Items []itemListResponse `json:"items"`
// HasMore lets clients paginate these personal lists. Computed from the
// raw store page size (== limit), not the resolved item count, so
// catalog-missing / hidden entries don't make a full page look final.
HasMore bool `json:"has_more"`
}
// resolveItems fetches full media item data for a list of entries.
-118
View File
@@ -1,118 +0,0 @@
package handlers
import (
"context"
"fmt"
"net/http"
"github.com/Silo-Server/silo-server/internal/access"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// userLookup is the slice of the user repository the household check needs: a
// profile token is only valid against the access-policy revision it was minted
// for, so verifying one means reading the user's current revision.
type userLookup interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// canManageHousehold reports whether the caller may act for the whole household
// — every profile on their own account — rather than only for themselves.
//
// Server admins always may. Otherwise the caller's active profile must be the
// one flagged is_primary, which is the household parent and is deliberately
// *not* the server-wide admin role: a household parent manages their family,
// an admin manages the server.
//
// When that primary profile has a PIN, management additionally requires a valid
// X-Profile-Token from /profiles/{id}/verify-pin. Without that, a client could
// walk past a profile lock by sending only X-Profile-Id.
//
// This is a policy boundary for well-behaved clients rather than a defense
// against the account holder: every profile on an account shares one login
// session, so X-Profile-Id is self-asserted (see the note in
// internal/api/middleware/auth.go). It is the same boundary profile management
// has always used, and it is applied here so household settings management is
// guarded and auditable rather than implicit.
func canManageHousehold(
r *http.Request,
store userstore.UserStore,
users userLookup,
tokens *access.ProfileTokenService,
) (bool, error) {
ctx := r.Context()
if apimw.IsAdmin(ctx) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
if activeProfileID == "" {
activeProfileID = r.Header.Get("X-Profile-Id")
}
if activeProfileID == "" {
return false, nil
}
active, err := store.GetProfile(ctx, activeProfileID)
if err != nil {
return false, err
}
if active == nil {
return false, nil
}
if !active.IsPrimary {
return false, nil
}
if active.PINHash == "" {
return true, nil
}
if err := verifyProfileToken(r, users, tokens, active.ID); err != nil {
return false, err
}
return true, nil
}
// verifyProfileToken checks the X-Profile-Token a PIN-locked profile must
// present. Missing dependencies fail closed: a handler wired without a token
// service cannot verify a PIN, and "cannot verify" is not "verified".
func verifyProfileToken(
r *http.Request,
users userLookup,
tokens *access.ProfileTokenService,
profileID string,
) error {
if users == nil || tokens == nil {
return access.ErrProfileUnverified
}
claims := apimw.GetClaims(r.Context())
if claims == nil || claims.SessionID == "" {
return access.ErrProfileUnverified
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
return access.ErrProfileUnverified
}
user, err := users.GetByID(r.Context(), userID)
if err != nil {
return fmt.Errorf("loading user policy: %w", err)
}
if user == nil {
return access.ErrProfileUnverified
}
profileClaims, err := tokens.Validate(r.Header.Get("X-Profile-Token"))
if err != nil {
return err
}
if profileClaims.UserID != userID ||
profileClaims.SessionID != claims.SessionID ||
profileClaims.ProfileID != profileID ||
profileClaims.PolicyRevision != user.AccessPolicyRevision {
return access.ErrProfileUnverified
}
return nil
}
-165
View File
@@ -1,165 +0,0 @@
package handlers
import (
"context"
"database/sql"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/access"
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/models"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type stubUserRepo struct {
user *models.User
}
func (s stubUserRepo) GetByID(context.Context, int) (*models.User, error) {
return s.user, nil
}
func newHouseholdTestStore(t *testing.T) 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)
}
return userdb.NewSQLiteUserStore(db)
}
func householdRequest(profileID string, admin bool, profileToken string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
claims := &auth.Claims{UserID: 1, SessionID: "session-1"}
if admin {
claims.Role = "admin"
}
ctx := apimw.SetClaims(req.Context(), claims)
if profileID != "" {
ctx = apimw.SetProfileID(ctx, profileID)
}
if profileToken != "" {
req.Header.Set("X-Profile-Token", profileToken)
}
return req.WithContext(ctx)
}
// TestCanManageHousehold pins the household-parent boundary before it is shared
// with the settings routes. The rule is deliberately narrow: a server admin, or
// the one profile flagged is_primary — and when that profile carries a PIN, a
// verified profile token, so sending only X-Profile-Id cannot walk past a
// profile lock.
func TestCanManageHousehold(t *testing.T) {
ctx := context.Background()
setup := func(t *testing.T, pin string) (userstore.UserStore, *access.ProfileTokenService) {
t.Helper()
store := newHouseholdTestStore(t)
if err := store.CreateProfile(ctx, userstore.Profile{
ID: "primary", Name: "Sam", IsPrimary: true,
}); err != nil {
t.Fatalf("create primary: %v", err)
}
if err := store.CreateProfile(ctx, userstore.Profile{
ID: "child", Name: "Robin",
}); err != nil {
t.Fatalf("create child: %v", err)
}
if pin != "" {
if err := store.UpdateProfile(ctx, "primary", userstore.UpdateProfileInput{
PIN: &pin,
}); err != nil {
t.Fatalf("set pin: %v", err)
}
}
return store, access.NewProfileTokenService("test-secret-value-at-least-32-chars", 0)
}
t.Run("admin always may", func(t *testing.T) {
store, tokens := setup(t, "")
// An admin with no active profile at all still manages.
ok, err := canManageHousehold(householdRequest("", true, ""), store, nil, tokens)
if err != nil || !ok {
t.Fatalf("admin = (%v, %v), want (true, nil)", ok, err)
}
})
t.Run("primary without pin may", func(t *testing.T) {
store, tokens := setup(t, "")
ok, err := canManageHousehold(householdRequest("primary", false, ""), store, nil, tokens)
if err != nil || !ok {
t.Fatalf("primary = (%v, %v), want (true, nil)", ok, err)
}
})
t.Run("non-primary may not", func(t *testing.T) {
store, tokens := setup(t, "")
ok, err := canManageHousehold(householdRequest("child", false, ""), store, nil, tokens)
if err != nil || ok {
t.Fatalf("non-primary = (%v, %v), want (false, nil)", ok, err)
}
})
t.Run("no active profile may not", func(t *testing.T) {
store, tokens := setup(t, "")
ok, err := canManageHousehold(householdRequest("", false, ""), store, nil, tokens)
if err != nil || ok {
t.Fatalf("no profile = (%v, %v), want (false, nil)", ok, err)
}
})
t.Run("unknown profile may not", func(t *testing.T) {
store, tokens := setup(t, "")
ok, err := canManageHousehold(householdRequest("ghost", false, ""), store, nil, tokens)
if err != nil || ok {
t.Fatalf("unknown profile = (%v, %v), want (false, nil)", ok, err)
}
})
t.Run("primary with pin and no token may not", func(t *testing.T) {
store, tokens := setup(t, "1234")
repo := stubUserRepo{user: &models.User{ID: 1}}
_, err := canManageHousehold(householdRequest("primary", false, ""), store, repo, tokens)
if !errors.Is(err, access.ErrProfileUnverified) {
t.Fatalf("pin without token err = %v, want ErrProfileUnverified", err)
}
})
t.Run("primary with pin and valid token may", func(t *testing.T) {
store, tokens := setup(t, "1234")
repo := stubUserRepo{user: &models.User{ID: 1}}
token, _, err := tokens.Mint(access.ProfileTokenClaims{
UserID: 1, SessionID: "session-1", ProfileID: "primary", PolicyRevision: 0,
})
if err != nil {
t.Fatalf("issuing token: %v", err)
}
ok, err := canManageHousehold(householdRequest("primary", false, token), store, repo, tokens)
if err != nil || !ok {
t.Fatalf("pin with token = (%v, %v), want (true, nil)", ok, err)
}
})
// Nil dependencies must fail closed. A settings handler built without a
// token service is not a reason to skip the PIN check.
t.Run("pin with no token service may not", func(t *testing.T) {
store, _ := setup(t, "1234")
repo := stubUserRepo{user: &models.User{ID: 1}}
_, err := canManageHousehold(householdRequest("primary", false, "tok"), store, repo, nil)
if !errors.Is(err, access.ErrProfileUnverified) {
t.Fatalf("nil token service err = %v, want ErrProfileUnverified", err)
}
})
}
-89
View File
@@ -1,89 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/invitations"
)
// InvitationHandler handles the public (unauthenticated) claim endpoints.
type InvitationHandler struct {
service *invitations.Service
}
// NewInvitationHandler creates a new InvitationHandler.
func NewInvitationHandler(service *invitations.Service) *InvitationHandler {
return &InvitationHandler{service: service}
}
type invitationLookupResponse struct {
Email string `json:"email"`
InviterName string `json:"inviter_name,omitempty"`
ServerName string `json:"server_name"`
ExpiresAt time.Time `json:"expires_at"`
ShowTour bool `json:"show_tour"`
}
type acceptInvitationRequest struct {
Password string `json:"password"`
}
// HandleLookupInvitation handles GET /invitations/{token}. Unknown, expired,
// revoked, and accepted tokens return an identical 404 so a probe learns
// nothing about which.
func (h *InvitationHandler) HandleLookupInvitation(w http.ResponseWriter, r *http.Request) {
result, err := h.service.Lookup(r.Context(), chi.URLParam(r, "token"))
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "This invitation is invalid or has expired")
return
}
writeJSON(w, http.StatusOK, invitationLookupResponse{
Email: result.Email,
InviterName: result.InviterName,
ServerName: result.ServerName,
ExpiresAt: result.ExpiresAt,
ShowTour: result.ShowTour,
})
}
// HandleAcceptInvitation handles POST /invitations/{token}/accept. On
// success it returns the same login response shape as signup, so clients
// reuse their existing session plumbing.
func (h *InvitationHandler) HandleAcceptInvitation(w http.ResponseWriter, r *http.Request) {
var req acceptInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "weak_password", "Password must be at least 8 characters")
return
}
pair, user, err := h.service.Accept(
r.Context(),
chi.URLParam(r, "token"),
req.Password,
r.UserAgent(),
clientip.FromContext(r.Context()),
)
if err != nil {
switch {
case errors.Is(err, invitations.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "This invitation is invalid or has expired")
case errors.Is(err, invitations.ErrNotClaimable):
writeError(w, http.StatusConflict, "already_used", "This invitation has already been used")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
}
return
}
writeJSON(w, http.StatusCreated, buildLoginResponse(pair, user, nil))
}
+9 -327
View File
@@ -21,7 +21,6 @@ import (
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/overlays"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchstate"
@@ -48,33 +47,6 @@ type MetadataRefreshRequester interface {
RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error
}
// TrailerRefreshRequester starts a viewer-triggered trailer fetch for one item
// and reports whether it was queued, in cooldown, or disabled by every
// containing library. Implemented by *metadata.MetadataService.
type TrailerRefreshRequester interface {
RequestTrailersRefresh(ctx context.Context, contentID string) (metadata.TrailerRefreshOutcome, error)
}
// trailerItemAccess resolves and authorizes the item behind the trailer
// refresh route. The concrete *catalog.ItemRepository satisfies it; the
// interface keeps the handler testable without a database.
type trailerItemAccess interface {
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
}
// trailerSeasonLookup and trailerEpisodeLookup resolve the content IDs that are
// not media_items rows. Season and episode detail pages carry their own IDs, so
// without these a client that asks for their trailers would get a 404 that
// looks like a missing item instead of the contracted "wrong type" answer.
type trailerSeasonLookup interface {
GetByID(ctx context.Context, contentID string) (*models.Season, error)
}
type trailerEpisodeLookup interface {
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
}
type LocalWatchEventDispatcher interface {
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
}
@@ -98,11 +70,6 @@ type ItemsHandler struct {
profileStaler ProfileStaler
profileRefreshRequester ProfileRefreshRequester
metadataRefreshRequester MetadataRefreshRequester
trailerRefreshRequester TrailerRefreshRequester
trailerItemAccess trailerItemAccess
trailerSeasonLookup trailerSeasonLookup
trailerEpisodeLookup trailerEpisodeLookup
trailerRefreshLimiter ratelimit.RateLimiter
localWatchDispatcher LocalWatchEventDispatcher
ebookProgressStore EbookReaderProgressLister
ebookReadStateStore EbookReadStateStore
@@ -163,47 +130,6 @@ func (h *ItemsHandler) SetMetadataRefreshRequester(requester MetadataRefreshRequ
h.metadataRefreshRequester = requester
}
// SetTrailerRefreshLimiter wires the process's configured rate limiter into the
// trailer fetch action, so the per-user budget is shared across instances when
// the deployment runs the Redis backend. A private in-memory limiter would give
// each instance its own allowance for the same user, and the per-item database
// cooldown cannot make up the difference — it bounds one item, while this
// budget bounds how many distinct items a user can start refreshes for.
//
// Call before SetTrailerRefreshRequester, which falls back to a private
// in-memory limiter when none is set (single-instance deployments, and any
// deployment with rate limiting turned off entirely).
func (h *ItemsHandler) SetTrailerRefreshLimiter(limiter ratelimit.RateLimiter) {
if h == nil || limiter == nil {
return
}
h.trailerRefreshLimiter = limiter
}
// SetTrailerRefreshRequester wires the viewer-facing trailer fetch action.
// Leaving it unset disables the route's behavior (503), so the router only
// registers it when the metadata service is available.
func (h *ItemsHandler) SetTrailerRefreshRequester(requester TrailerRefreshRequester) {
if h == nil {
return
}
h.trailerRefreshRequester = requester
if h.trailerRefreshLimiter == nil {
h.trailerRefreshLimiter = ratelimit.NewMemoryLimiter()
}
if h.trailerItemAccess == nil && h.itemRepo != nil {
h.trailerItemAccess = h.itemRepo
}
// Seasons and episodes are not media_items rows, so the route needs these
// to tell "this ID is an episode" from "no such content".
if h.trailerSeasonLookup == nil && h.seasonRepo != nil {
h.trailerSeasonLookup = h.seasonRepo
}
if h.trailerEpisodeLookup == nil && h.episodeRepo != nil {
h.trailerEpisodeLookup = h.episodeRepo
}
}
func (h *ItemsHandler) SetCatalogSearchProvider(provider catalog.CatalogSearchProvider) {
if h == nil || h.catalogResolver == nil || provider == nil {
return
@@ -502,238 +428,6 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, detail)
}
// trailerRefreshRate bounds how often one user may trigger trailer fetches
// across all items. The per-item cooldown enforced by the metadata service is
// the real budget; this only keeps a misbehaving client from hammering the
// endpoint (same shape as personRefreshRate).
var trailerRefreshRate = ratelimit.Rate{
RequestsPerSecond: 10,
RequestsPerMinute: 10,
Burst: 10,
}
// trailerRefreshLimiterKey namespaces this action's per-user counter. The
// limiter behind it is normally the process-wide one shared with the rate-limit
// middleware, whose keys are namespaced the same way ("ip:", "key:").
func trailerRefreshLimiterKey(userID int) string {
return "trailers:" + strconv.Itoa(userID)
}
// trailerRefreshResponse is the body of the trailer refresh endpoint.
// NextAllowedAt is present only for the cooldown status.
type trailerRefreshResponse struct {
Status string `json:"status"`
NextAllowedAt string `json:"next_allowed_at,omitempty"`
}
// trailerRefreshCapabilityResponse tells a client whether this server offers
// the viewer-facing trailer fetch, following the per-subsystem convention
// (/events/capability, /playback/capability, /ebooks/capability).
//
// Without it the only signal is a 404 from the POST, which a client cannot
// tell apart from a missing item — and the route is registered conditionally
// (it needs the metadata service to implement the optional interface), so
// "this build has the feature" is not the same question as "this deployment
// serves it". A client that finds refresh false should hide the action rather
// than offer a button that cannot work.
type trailerRefreshCapabilityResponse struct {
SchemaVersion int `json:"schema_version"`
// Refresh reports that POST /items/{id}/trailers/refresh is served here.
Refresh bool `json:"refresh"`
// CooldownSeconds is the per-item window between viewer-triggered
// refreshes, so a client can explain the wait without having received a
// cooldown response first.
CooldownSeconds int `json:"cooldown_seconds"`
// Statuses is every value the refresh endpoint's status field may take.
Statuses []string `json:"statuses"`
// SupportedTypes is the item types the action applies to; nothing else
// carries remote videos, so clients should not show the action elsewhere.
SupportedTypes []string `json:"supported_types"`
}
// HandleTrailerRefreshCapability reports whether the trailer refresh action is
// available. GET /api/v1/items/trailers/capability.
//
// It answers even when the feature is unwired, because "refresh": false is the
// answer in that case; the router registers it unconditionally so a client
// never has to interpret a 404 on the probe itself.
func (h *ItemsHandler) HandleTrailerRefreshCapability(w http.ResponseWriter, _ *http.Request) {
enabled := h != nil && h.trailerRefreshRequester != nil && h.trailerItemAccess != nil
resp := trailerRefreshCapabilityResponse{
SchemaVersion: 1,
Refresh: enabled,
Statuses: []string{},
SupportedTypes: []string{},
}
if enabled {
resp.CooldownSeconds = int(metadata.TrailerRefreshCooldown / time.Second)
resp.Statuses = []string{
metadata.TrailerRefreshStatusQueued,
metadata.TrailerRefreshStatusCooldown,
metadata.TrailerRefreshStatusDisabled,
}
resp.SupportedTypes = []string{"movie", "series"}
}
writeJSON(w, http.StatusOK, resp)
}
// HandleRequestTrailersRefresh handles POST /api/v1/items/{id}/trailers/refresh:
// any authenticated viewer with access to a movie or series may ask the server
// to fetch its remote trailers, at most once per item per cooldown window.
//
// "cooldown" and "disabled" are expected client-rendered states, not errors,
// so they answer 200; 429 stays reserved for the per-user limiter. The access
// check runs before the metadata service is called so a caller who cannot see
// the item can never consume its cooldown slot. A season or episode ID resolves
// through its own table to 400 unsupported-type; only genuinely unknown content
// answers 404.
func (h *ItemsHandler) HandleRequestTrailersRefresh(w http.ResponseWriter, r *http.Request) {
if h == nil || h.trailerRefreshRequester == nil || h.trailerItemAccess == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Trailer refresh is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h.trailerRefreshLimiter != nil {
// The limiter may be the process-wide one the middleware uses, so the
// key is namespaced: an unprefixed user id would share a counter with
// whatever else keys on the same string.
result := h.trailerRefreshLimiter.Allow(r.Context(), trailerRefreshLimiterKey(userID), trailerRefreshRate)
if !result.Allowed {
if result.RetryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(result.RetryAfter.Seconds()))))
}
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many trailer refresh requests")
return
}
}
target, err := h.resolveTrailerRefreshTarget(r.Context(), contentID)
if err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.ErrorContext(r.Context(), "trailers: failed to look up item", "component", "api",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
return
}
// Authorize against the series for a season or episode ID, exactly as the
// on-view translation route does, so an unsupported-type answer never
// leaks the existence of content the caller cannot see.
if err := h.trailerItemAccess.EnsureAccessible(r.Context(), target.accessContentID, h.accessFilter(r)); err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.ErrorContext(r.Context(), "trailers: failed to authorize item", "component", "api",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
return
}
// Only movie and series detail responses carry videos/extras, so anything
// else — another media_items type, or a season/episode ID, which is not a
// media_items row at all — is a client bug rather than an empty result.
if !target.supportsTrailers {
writeError(w, http.StatusBadRequest, "unsupported_type", "Trailers are only available for movies and series")
return
}
outcome, err := h.trailerRefreshRequester.RequestTrailersRefresh(r.Context(), contentID)
if err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.ErrorContext(r.Context(), "trailers: failed to request refresh", "component", "api",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request trailers")
return
}
switch outcome.Status {
case metadata.TrailerRefreshStatusQueued:
writeJSON(w, http.StatusAccepted, trailerRefreshResponse{Status: outcome.Status})
case metadata.TrailerRefreshStatusCooldown:
resp := trailerRefreshResponse{Status: outcome.Status}
if outcome.NextAllowedAt != nil {
resp.NextAllowedAt = outcome.NextAllowedAt.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, resp)
case metadata.TrailerRefreshStatusDisabled:
writeJSON(w, http.StatusOK, trailerRefreshResponse{Status: outcome.Status})
default:
slog.ErrorContext(r.Context(), "trailers: unexpected refresh outcome", "component", "api",
"content_id", contentID, "status", outcome.Status)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request trailers")
}
}
// trailerRefreshTarget is what a content ID on the trailer refresh route turned
// out to be: whether trailers apply to it at all, and which item ID authorizes
// it (a season or episode is authorized through its series).
type trailerRefreshTarget struct {
supportsTrailers bool
accessContentID string
}
// resolveTrailerRefreshTarget identifies the content behind an ID the same way
// the on-view translation route does. Seasons and episodes live in their own
// tables, so a media_items miss is not proof the content is absent — falling
// through to those lookups is what lets a real episode ID answer 400
// unsupported-type instead of a misleading 404.
func (h *ItemsHandler) resolveTrailerRefreshTarget(ctx context.Context, contentID string) (trailerRefreshTarget, error) {
item, err := h.trailerItemAccess.GetByID(ctx, contentID)
switch {
case err == nil && item != nil:
return trailerRefreshTarget{
supportsTrailers: item.Type == "movie" || item.Type == "series",
accessContentID: contentID,
}, nil
case err == nil, errors.Is(err, catalog.ErrItemNotFound):
// Fall through to the season and episode lookups.
default:
return trailerRefreshTarget{}, err
}
if h.trailerSeasonLookup != nil {
season, err := h.trailerSeasonLookup.GetByID(ctx, contentID)
switch {
case err == nil && season != nil:
return trailerRefreshTarget{accessContentID: season.SeriesID}, nil
case err == nil, errors.Is(err, catalog.ErrSeasonNotFound):
default:
return trailerRefreshTarget{}, err
}
}
if h.trailerEpisodeLookup != nil {
episode, err := h.trailerEpisodeLookup.GetByID(ctx, contentID)
switch {
case err == nil && episode != nil:
return trailerRefreshTarget{accessContentID: episode.SeriesID}, nil
case err == nil, errors.Is(err, catalog.ErrEpisodeNotFound):
default:
return trailerRefreshTarget{}, err
}
}
return trailerRefreshTarget{}, catalog.ErrItemNotFound
}
// HandleMarkWatched handles POST /watched/{id}.
func (h *ItemsHandler) HandleMarkWatched(w http.ResponseWriter, r *http.Request) {
h.handleSetWatchedState(w, r, true)
@@ -1784,18 +1478,7 @@ func (h *ItemsHandler) toSeasonResponseFromEpisodes(
s = localized
}
}
return h.seasonResponseFromEpisodes(r, s, episodes, userData)
}
// seasonResponseFromEpisodes maps a season that has already been localized.
// List endpoints use this after LocalizeSeasonModels so they do not repeat the
// localization query for every row.
func (h *ItemsHandler) seasonResponseFromEpisodes(
r *http.Request,
s *models.Season,
episodes []*models.Episode,
userData *catalog.SeasonUserData,
) seasonResponse {
resp := seasonResponse{
ContentID: s.ContentID,
SeasonNumber: s.SeasonNumber,
@@ -2131,16 +1814,15 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
MetadataLanguageOverrides: scope.MetadataLanguageOverrides,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
}
}
@@ -1,571 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"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"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/ratelimit"
)
type fakeTrailerItemAccess struct {
items map[string]*models.MediaItem
ensureErr map[string]error
getErr map[string]error
checked []string
}
func (f *fakeTrailerItemAccess) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
if err := f.getErr[contentID]; err != nil {
return nil, err
}
if item := f.items[contentID]; item != nil {
return item, nil
}
return nil, catalog.ErrItemNotFound
}
func (f *fakeTrailerItemAccess) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
f.checked = append(f.checked, contentID)
return f.ensureErr[contentID]
}
type fakeTrailerRefreshRequester struct {
outcome metadata.TrailerRefreshOutcome
err error
requests []string
}
func (f *fakeTrailerRefreshRequester) RequestTrailersRefresh(_ context.Context, contentID string) (metadata.TrailerRefreshOutcome, error) {
f.requests = append(f.requests, contentID)
if f.err != nil {
return metadata.TrailerRefreshOutcome{}, f.err
}
return f.outcome, nil
}
// fakeTrailerSeasonLookup and fakeTrailerEpisodeLookup stand in for the season
// and episode tables. Their content IDs are real and resolvable, they are just
// not media_items rows — which is exactly why the route needs them.
type fakeTrailerSeasonLookup map[string]*models.Season
func (f fakeTrailerSeasonLookup) GetByID(_ context.Context, contentID string) (*models.Season, error) {
if season := f[contentID]; season != nil {
return season, nil
}
return nil, catalog.ErrSeasonNotFound
}
type fakeTrailerEpisodeLookup map[string]*models.Episode
func (f fakeTrailerEpisodeLookup) GetByID(_ context.Context, contentID string) (*models.Episode, error) {
if episode := f[contentID]; episode != nil {
return episode, nil
}
return nil, catalog.ErrEpisodeNotFound
}
func newTrailerRefreshHandler(
access *fakeTrailerItemAccess,
requester *fakeTrailerRefreshRequester,
) *ItemsHandler {
return &ItemsHandler{
trailerItemAccess: access,
trailerRefreshRequester: requester,
trailerRefreshLimiter: ratelimit.NewMemoryLimiter(),
trailerSeasonLookup: fakeTrailerSeasonLookup{},
trailerEpisodeLookup: fakeTrailerEpisodeLookup{},
}
}
func newTrailerRefreshRequest(contentID string, userID int) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/items/"+contentID+"/trailers/refresh", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", contentID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: userID, Role: "user", TokenType: auth.TokenTypeAccess})
ctx = apimw.SetProfileID(ctx, "profile-1")
ctx = access.SetScope(ctx, access.Scope{UserID: userID, ProfileID: "profile-1"})
return req.WithContext(ctx)
}
func decodeTrailerResponse(t *testing.T, rr *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body %q: %v", rr.Body.String(), err)
}
return body
}
// The router discovers both seams by type assertion, so a signature drift
// would silently unregister the route rather than fail the build.
func TestTrailerRefreshWiringAssertionsHold(t *testing.T) {
var svc any = (*metadata.MetadataService)(nil)
if _, ok := svc.(TrailerRefreshRequester); !ok {
t.Fatal("*metadata.MetadataService must satisfy handlers.TrailerRefreshRequester")
}
var repo any = (*catalog.ItemRepository)(nil)
if _, ok := repo.(trailerItemAccess); !ok {
t.Fatal("*catalog.ItemRepository must satisfy trailerItemAccess")
}
// SetTrailerRefreshRequester adopts these from the handler's own repos, so
// drift here would silently downgrade every episode ID back to a 404.
var seasons any = (*catalog.SeasonRepository)(nil)
if _, ok := seasons.(trailerSeasonLookup); !ok {
t.Fatal("*catalog.SeasonRepository must satisfy trailerSeasonLookup")
}
var episodes any = (*catalog.EpisodeRepository)(nil)
if _, ok := episodes.(trailerEpisodeLookup); !ok {
t.Fatal("*catalog.EpisodeRepository must satisfy trailerEpisodeLookup")
}
}
func TestTrailersRefreshReturnsQueued(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusAccepted, rr.Body.String())
}
body := decodeTrailerResponse(t, rr)
if body["status"] != "queued" {
t.Fatalf("status field = %v, want queued", body["status"])
}
if _, ok := body["next_allowed_at"]; ok {
t.Fatalf("queued response must omit next_allowed_at, got %v", body)
}
if len(requester.requests) != 1 || requester.requests[0] != "movie-1" {
t.Fatalf("requests = %v, want [movie-1]", requester.requests)
}
}
func TestTrailersRefreshReturnsCooldownWithNextAllowedAt(t *testing.T) {
next := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{
Status: metadata.TrailerRefreshStatusCooldown,
NextAllowedAt: &next,
},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("series-1", 7))
// Cooldown is an expected client-rendered state, not an error: 200, and
// 429 stays reserved for the per-user limiter.
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusOK, rr.Body.String())
}
body := decodeTrailerResponse(t, rr)
if body["status"] != "cooldown" {
t.Fatalf("status field = %v, want cooldown", body["status"])
}
if got := body["next_allowed_at"]; got != next.Format(time.RFC3339) {
t.Fatalf("next_allowed_at = %v, want %s", got, next.Format(time.RFC3339))
}
}
func TestTrailersRefreshReturnsDisabled(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusDisabled},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusOK, rr.Body.String())
}
body := decodeTrailerResponse(t, rr)
if body["status"] != "disabled" {
t.Fatalf("status field = %v, want disabled", body["status"])
}
if _, ok := body["next_allowed_at"]; ok {
t.Fatalf("disabled response must omit next_allowed_at, got %v", body)
}
}
// Only movie and series detail responses carry videos, so any other
// media_items type is a client bug rather than an empty result. These are the
// types that actually exist as media_items rows; episodes and seasons live in
// their own tables and are covered separately below.
func TestTrailersRefreshRejectsNonMovieSeriesTypes(t *testing.T) {
for _, itemType := range []string{"audiobook", "ebook", "manga"} {
t.Run(itemType, func(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"item-1": {ContentID: "item-1", Type: itemType}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("item-1", 7))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusBadRequest, rr.Body.String())
}
if len(requester.requests) != 0 {
t.Fatalf("unsupported type must not reach the service, got %v", requester.requests)
}
})
}
}
// Episodes and seasons are not media_items rows, so the item lookup misses on
// their real content IDs. Without the fallbacks the route would answer 404
// "Item not found" for content that plainly exists; the contract is 400
// unsupported-type. Authorization runs against the parent series, as on the
// on-view translation route.
func TestTrailersRefreshRejectsEpisodeAndSeasonIDsWith400(t *testing.T) {
tests := []struct {
name string
contentID string
wantAccess string
}{
{name: "episode", contentID: "episode-1", wantAccess: "series-1"},
{name: "season", contentID: "season-1", wantAccess: "series-1"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
handler.trailerSeasonLookup = fakeTrailerSeasonLookup{
"season-1": {ContentID: "season-1", SeriesID: "series-1"},
}
handler.trailerEpisodeLookup = fakeTrailerEpisodeLookup{
"episode-1": {ContentID: "episode-1", SeriesID: "series-1"},
}
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest(tc.contentID, 7))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusBadRequest, rr.Body.String())
}
body := decodeTrailerResponse(t, rr)
if code, _ := body["error"].(string); code != "unsupported_type" {
t.Fatalf("error code = %v, want unsupported_type (%s)", body["error"], rr.Body.String())
}
if len(itemAccess.checked) != 1 || itemAccess.checked[0] != tc.wantAccess {
t.Fatalf("access checks = %v, want [%s]", itemAccess.checked, tc.wantAccess)
}
if len(requester.requests) != 0 {
t.Fatalf("unsupported type must not reach the service, got %v", requester.requests)
}
})
}
}
// An episode inside a series the caller cannot see must not be distinguishable
// from content that does not exist, so the access check runs before the type
// answer.
func TestTrailersRefreshEpisodeInInaccessibleSeriesReturns404(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
ensureErr: map[string]error{"series-1": catalog.ErrItemNotFound},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
handler.trailerEpisodeLookup = fakeTrailerEpisodeLookup{
"episode-1": {ContentID: "episode-1", SeriesID: "series-1"},
}
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("episode-1", 7))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
}
if len(requester.requests) != 0 {
t.Fatalf("denied request must not reach the service, got %v", requester.requests)
}
}
// An unauthorized caller must be turned away before the metadata service is
// asked, so it can never burn the item's cooldown slot.
func TestTrailersRefreshDeniedAccessReturns404WithoutConsumingCooldown(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{"movie-1": catalog.ErrItemNotFound},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
}
if len(itemAccess.checked) != 1 {
t.Fatalf("access checks = %v, want one check", itemAccess.checked)
}
if len(requester.requests) != 0 {
t.Fatalf("denied request must not reach the service, got %v", requester.requests)
}
}
func TestTrailersRefreshMissingItemReturns404(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("missing", 7))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
}
if len(requester.requests) != 0 {
t.Fatalf("missing item must not reach the service, got %v", requester.requests)
}
}
func TestTrailersRefreshRequiresAuthentication(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
req := httptest.NewRequest(http.MethodPost, "/api/v1/items/movie-1/trailers/refresh", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", "movie-1")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusUnauthorized, rr.Body.String())
}
if len(requester.requests) != 0 {
t.Fatalf("unauthenticated request must not reach the service, got %v", requester.requests)
}
}
// The per-user limiter is the abuse guard in front of the per-item cooldown:
// once a user exhausts the burst it answers 429 with Retry-After.
func TestTrailersRefreshRateLimitsPerUser(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
limited := false
for i := 0; i < int(trailerRefreshRate.RequestsPerMinute)+5; i++ {
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code == http.StatusTooManyRequests {
limited = true
if rr.Header().Get("Retry-After") == "" {
t.Fatal("429 response must carry Retry-After")
}
break
}
}
if !limited {
t.Fatal("expected the per-user limiter to reject a burst of requests")
}
// A different user is unaffected — the limiter keys on the user id.
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 8))
if rr.Code != http.StatusAccepted {
t.Fatalf("second user status = %d, want %d (%s)", rr.Code, http.StatusAccepted, rr.Body.String())
}
}
func TestTrailersRefreshUnconfiguredReturns503(t *testing.T) {
handler := &ItemsHandler{}
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusServiceUnavailable, rr.Body.String())
}
}
func TestTrailersRefreshServiceErrorReturns500(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{err: errors.New("database is down")}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusInternalServerError, rr.Body.String())
}
}
// The capability probe is what lets a client tell "this server does not have
// the trailer action" from "that item does not exist", so it must answer on
// both a wired and an unwired handler.
func TestTrailerRefreshCapability(t *testing.T) {
t.Run("wired", func(t *testing.T) {
h := newTrailerRefreshHandler(&fakeTrailerItemAccess{}, &fakeTrailerRefreshRequester{})
rr := httptest.NewRecorder()
h.HandleTrailerRefreshCapability(rr, httptest.NewRequest(http.MethodGet, "/items/trailers/capability", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := decodeTrailerResponse(t, rr)
if body["refresh"] != true {
t.Fatalf("refresh = %v, want true", body["refresh"])
}
if got, want := body["cooldown_seconds"], float64(metadata.TrailerRefreshCooldown/time.Second); got != want {
t.Fatalf("cooldown_seconds = %v, want %v", got, want)
}
// The advertised statuses are the contract the client switches on, so
// they must be the service's constants rather than a stale copy.
statuses, _ := body["statuses"].([]any)
want := []string{
metadata.TrailerRefreshStatusQueued,
metadata.TrailerRefreshStatusCooldown,
metadata.TrailerRefreshStatusDisabled,
}
if len(statuses) != len(want) {
t.Fatalf("statuses = %v, want %v", statuses, want)
}
for i, status := range want {
if statuses[i] != status {
t.Fatalf("statuses[%d] = %v, want %q", i, statuses[i], status)
}
}
})
t.Run("unwired", func(t *testing.T) {
h := &ItemsHandler{}
rr := httptest.NewRecorder()
h.HandleTrailerRefreshCapability(rr, httptest.NewRequest(http.MethodGet, "/items/trailers/capability", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 — the probe itself must never 404", rr.Code)
}
body := decodeTrailerResponse(t, rr)
if body["refresh"] != false {
t.Fatalf("refresh = %v, want false", body["refresh"])
}
})
}
// recordingLimiter captures the keys an action limiter is called with.
type recordingLimiter struct {
keys []string
allowed bool
}
func (l *recordingLimiter) Allow(_ context.Context, key string, _ ratelimit.Rate) ratelimit.AllowResult {
l.keys = append(l.keys, key)
return ratelimit.AllowResult{Allowed: l.allowed, RetryAfter: time.Second}
}
func (l *recordingLimiter) Close() {}
// The action's budget must be enforced by the process's configured limiter, or
// a Redis deployment gives every instance an independent allowance for the same
// user and multiplies the stated budget by the instance count. The per-item
// database cooldown cannot compensate: it bounds one item, while this bounds
// how many distinct items a user can start refreshes for.
func TestTrailersRefreshUsesTheInjectedSharedLimiter(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
shared := &recordingLimiter{allowed: false}
handler.SetTrailerRefreshLimiter(shared)
// The requester wiring must not replace an injected limiter with a private
// in-memory one, which is the whole point of injecting it.
handler.SetTrailerRefreshRequester(requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want %d — the injected limiter's verdict was ignored (%s)",
rr.Code, http.StatusTooManyRequests, rr.Body.String())
}
if len(shared.keys) != 1 {
t.Fatalf("shared limiter consulted %d times, want 1", len(shared.keys))
}
// The limiter may be the process-wide one, whose keyspace is shared with
// the rate-limit middleware ("ip:", "key:"), so this action's keys have to
// be namespaced too.
if shared.keys[0] != trailerRefreshLimiterKey(7) {
t.Fatalf("limiter key = %q, want the namespaced %q", shared.keys[0], trailerRefreshLimiterKey(7))
}
if shared.keys[0] == "7" {
t.Fatal("an unprefixed user id would collide with other keyspaces in a shared limiter")
}
if len(requester.requests) != 0 {
t.Fatalf("a rate-limited request must not reach the service, got %v", requester.requests)
}
}
// Rate limiting can be disabled outright (or the database unavailable), in
// which case there is no shared limiter to inject. The action keeps its own
// in-memory guard rather than running unbounded.
func TestTrailersRefreshFallsBackToAPrivateLimiter(t *testing.T) {
handler := &ItemsHandler{}
handler.SetTrailerRefreshLimiter(nil)
handler.SetTrailerRefreshRequester(&fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
})
if handler.trailerRefreshLimiter == nil {
t.Fatal("the action must keep a limiter even when no shared one is configured")
}
}
+1 -48
View File
@@ -4,10 +4,8 @@ import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/jellycompat"
@@ -236,7 +234,7 @@ func TestRemoveJellyfinCompatWebDisablesWebSetting(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.web_enabled": "true",
"jellyfin_compat.web_install_dir": asyncWebInstallRoot(t),
"jellyfin_compat.web_install_dir": t.TempDir(),
}}
published := map[string]string{}
handler := &AdminHandler{
@@ -339,48 +337,3 @@ func TestPersistJellyfinCompatWebInstallSettingsEnablesWebUI(t *testing.T) {
t.Fatalf("jellyfin_compat.web_source_url = %q", got)
}
}
// asyncWebInstallRoot returns a temp dir for a handler that removes or installs
// Jellyfin Web assets in a background goroutine.
//
// t.TempDir is wrong here: the endpoint returns 202 and its goroutine keeps
// writing into the root after the test body returns, so t.TempDir's cleanup
// trips "directory not empty" on an otherwise passing test.
//
// Removing the directory out from under a running goroutine only moves the
// race, though: a write landing mid-traversal recreates a path RemoveAll has
// already walked past, and the leftovers survive the run. The operation
// records its own terminal state, so cleanup waits for that instead.
func asyncWebInstallRoot(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "jellyfin-web-root-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() {
waitForWebOperation(t, dir)
if err := os.RemoveAll(dir); err != nil {
t.Errorf("removing %s: %v", dir, err)
}
})
return dir
}
// waitForWebOperation blocks until the background install/remove goroutine for
// root has reached a terminal state, or gives up after a bound generous enough
// that only a genuinely stuck operation reaches it.
func waitForWebOperation(t *testing.T, root string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
op := jellycompat.CurrentWebOperation(root)
if op == nil || op.State != jellycompat.WebComponentOperationRunning {
return
}
if time.Now().After(deadline) {
t.Errorf("background %s operation on %s did not finish", op.Kind, root)
return
}
time.Sleep(5 * time.Millisecond)
}
}
+4 -23
View File
@@ -1249,7 +1249,6 @@ func scanRunResultFromIngest(result *libraryingest.Result) *evt.ScanRunResult {
resp.Updated = result.ScanResult.Updated
resp.Unchanged = result.ScanResult.Unchanged
resp.Missing = result.ScanResult.Missing
resp.MissingSkippedProtected = result.ScanResult.MissingSkippedProtected
resp.FilesDeleted = result.ScanResult.FilesDeleted
resp.MembershipsRemoved = result.ScanResult.MembershipsRemoved
resp.ItemsDeleted = result.ScanResult.ItemsDeleted
@@ -2340,18 +2339,13 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque
Title string
Year int
ContentType string
Status string
TmdbID string
TvdbID string
ImdbID string
LibraryID int
LibraryName string
}
items := make(map[string]itemInfo, len(contentIDs))
rows, err := h.pool.Query(r.Context(), `
SELECT mi.content_id, mi.title, mi.year, mi.type, COALESCE(mi.status, ''),
COALESCE(mi.tmdb_id, ''), COALESCE(mi.tvdb_id, ''), COALESCE(mi.imdb_id, ''),
SELECT mi.content_id, mi.title, mi.year, mi.type,
COALESCE(mf_lib.folder_id, 0),
COALESCE(mf_lib.folder_name, '')
FROM media_items mi
@@ -2372,31 +2366,18 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque
defer rows.Close()
for rows.Next() {
var cid, title, ctype, status, tmdbID, tvdbID, imdbID, libName string
var cid, title, ctype, libName string
var year, libID int
if err := rows.Scan(&cid, &title, &year, &ctype, &status, &tmdbID, &tvdbID, &imdbID, &libID, &libName); err != nil {
if err := rows.Scan(&cid, &title, &year, &ctype, &libID, &libName); err != nil {
slog.ErrorContext(r.Context(), "scanning item for stale IDs", "component", "api", "error", err)
continue
}
items[cid] = itemInfo{
Title: title, Year: year, ContentType: ctype, Status: status,
TmdbID: tmdbID, TvdbID: tvdbID, ImdbID: imdbID,
LibraryID: libID, LibraryName: libName,
}
items[cid] = itemInfo{Title: title, Year: year, ContentType: ctype, LibraryID: libID, LibraryName: libName}
}
resp := make([]staleMediaIDResponse, 0, len(staleIDs))
for _, s := range staleIDs {
info := items[s.ContentID]
if !metadata.IsActionableStaleProviderID(&models.MediaItem{
ContentID: s.ContentID,
Status: info.Status,
TmdbID: info.TmdbID,
TvdbID: info.TvdbID,
ImdbID: info.ImdbID,
}, s) {
continue
}
resp = append(resp, staleMediaIDResponse{
ContentID: s.ContentID,
LibraryID: info.LibraryID,
@@ -11,10 +11,7 @@ import (
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -26,7 +23,6 @@ type libraryLookup interface {
type LibraryPlaybackPrefHandler struct {
storeProvider userstore.UserStoreProvider
libraryLookup libraryLookup
EventsHub *evt.Hub
}
// NewLibraryPlaybackPrefHandler creates a new LibraryPlaybackPrefHandler.
@@ -110,11 +106,6 @@ func (h *LibraryPlaybackPrefHandler) HandleSetLibraryPlaybackPref(w http.Respons
writeError(w, http.StatusBadRequest, "bad_request", "Invalid subtitle_mode")
return
}
sync, err := planLibraryPlaybackSettingsSync(req)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
@@ -144,10 +135,7 @@ func (h *LibraryPlaybackPrefHandler) HandleSetLibraryPlaybackPref(w http.Respons
}
if !pref.HasAudioLanguage && !pref.HasSubtitleLanguage && !pref.HasSubtitleMode && !pref.HasShowForcedSubtitles {
if err := h.applyLibraryPlaybackSettingsSync(r.Context(), store, userID,
profileID, libraryID, sync, func(tx userstore.PreferenceSettingsWriter) error {
return tx.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID)
}); err != nil {
if err := store.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete library playback preference")
return
}
@@ -155,11 +143,8 @@ func (h *LibraryPlaybackPrefHandler) HandleSetLibraryPlaybackPref(w http.Respons
return
}
if err := h.applyLibraryPlaybackSettingsSync(r.Context(), store, userID,
profileID, libraryID, sync, func(tx userstore.PreferenceSettingsWriter) error {
return tx.UpsertLibraryPlaybackPreference(r.Context(), pref)
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store library playback preference")
if err := store.UpsertLibraryPlaybackPreference(r.Context(), pref); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set library playback preference")
return
}
@@ -184,15 +169,7 @@ func (h *LibraryPlaybackPrefHandler) HandleDeleteLibraryPlaybackPref(w http.Resp
return
}
if err := h.applyLibraryPlaybackSettingsSync(r.Context(), store, userID,
profileID, libraryID, []profileSettingSync{
{key: settingskeys.PlaybackAudioLanguage},
{key: settingskeys.PlaybackSubtitleLanguage},
{key: settingskeys.PlaybackSubtitleMode},
{key: settingskeys.PlaybackShowForcedSubtitles},
}, func(tx userstore.PreferenceSettingsWriter) error {
return tx.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID)
}); err != nil {
if err := store.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete library playback preference")
return
}
@@ -200,47 +177,6 @@ func (h *LibraryPlaybackPrefHandler) HandleDeleteLibraryPlaybackPref(w http.Resp
w.WriteHeader(http.StatusNoContent)
}
func planLibraryPlaybackSettingsSync(req setLibraryPlaybackPrefRequest) ([]profileSettingSync, error) {
out := make([]profileSettingSync, 0, 4)
for _, field := range []struct {
key string
raw *string
}{
{settingskeys.PlaybackAudioLanguage, req.AudioLanguage},
{settingskeys.PlaybackSubtitleLanguage, req.SubtitleLanguage},
{settingskeys.PlaybackSubtitleMode, req.SubtitleMode},
} {
if field.raw == nil {
out = append(out, profileSettingSync{key: field.key})
continue
}
var err error
out, err = appendStringSync(out, field.key, field.raw)
if err != nil {
return nil, err
}
}
forced := profileSettingSync{key: settingskeys.PlaybackShowForcedSubtitles}
if req.ShowForcedSubtitles != nil {
forced.value = json.RawMessage(strconv.FormatBool(*req.ShowForcedSubtitles))
}
return append(out, forced), nil
}
func (h *LibraryPlaybackPrefHandler) applyLibraryPlaybackSettingsSync(
ctx context.Context,
store userstore.UserStore,
userID int,
profileID string,
libraryID int,
writes []profileSettingSync,
legacyMutation func(userstore.PreferenceSettingsWriter) error,
) error {
return applyLegacyPreferenceSettingsSync(ctx, store, h.EventsHub, userID, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileLibrary, ProfileID: profileID, LibraryID: libraryID,
}, writes, legacyMutation)
}
func parseLibraryID(w http.ResponseWriter, r *http.Request) (int, bool) {
libraryIDStr := chi.URLParam(r, "library_id")
if libraryIDStr == "" {
@@ -1,101 +0,0 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func routeLibraryPlaybackPref(
t *testing.T,
h *LibraryPlaybackPrefHandler,
method string,
libraryID string,
body []byte,
) *httptest.ResponseRecorder {
t.Helper()
req := valuesRequest(method, "/library-playback-prefs/"+libraryID, body)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("library_id", libraryID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
if method == http.MethodPut {
h.HandleSetLibraryPlaybackPref(rec, req)
} else {
h.HandleDeleteLibraryPlaybackPref(rec, req)
}
return rec
}
func TestLegacyLibraryPlaybackWritesStayInCanonicalSync(t *testing.T) {
_, store := newValuesTestHandler(t)
handler := NewLibraryPlaybackPrefHandler(testUserStoreProvider{store: store})
rec := routeLibraryPlaybackPref(t, handler, http.MethodPut, "7", []byte(`{
"audio_language":"ja",
"subtitle_language":"de",
"subtitle_mode":"always",
"show_forced_subtitles":false
}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
want := map[string]string{
settingskeys.PlaybackAudioLanguage: `"ja"`,
settingskeys.PlaybackSubtitleLanguage: `"de"`,
settingskeys.PlaybackSubtitleMode: `"always"`,
settingskeys.PlaybackShowForcedSubtitles: `false`,
}
for key, expected := range want {
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileLibrary,
ProfileID: "profile-1", LibraryID: 7,
})
if err != nil || value == nil {
t.Fatalf("reading canonical %s: value=%+v err=%v", key, value, err)
}
if string(value.Value) != expected {
t.Errorf("%s = %s, want %s", key, value.Value, expected)
}
}
// The legacy PUT replaces the combined row. Omitting three fields clears
// their canonical overrides rather than leaving the backfilled values live.
rec = routeLibraryPlaybackPref(t, handler, http.MethodPut, "7", []byte(`{"audio_language":"fr"}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("replacement PUT = %d: %s", rec.Code, rec.Body.String())
}
for _, key := range []string{
settingskeys.PlaybackSubtitleLanguage,
settingskeys.PlaybackSubtitleMode,
settingskeys.PlaybackShowForcedSubtitles,
} {
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileLibrary,
ProfileID: "profile-1", LibraryID: 7,
})
if err != nil || value != nil {
t.Errorf("omitted %s was not cleared: value=%+v err=%v", key, value, err)
}
}
rec = routeLibraryPlaybackPref(t, handler, http.MethodDelete, "7", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage, Scope: settingscontract.ScopeProfileLibrary,
ProfileID: "profile-1", LibraryID: 7,
})
if err != nil || value != nil {
t.Fatalf("DELETE left canonical audio value=%+v err=%v", value, err)
}
}
-141
View File
@@ -1,141 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/onboarding"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// OnboardingHandler serves the tour manifest and per-profile progress.
type OnboardingHandler struct {
storeProvider userstore.UserStoreProvider
gates onboarding.Gates
}
// NewOnboardingHandler creates a new OnboardingHandler.
func NewOnboardingHandler(provider userstore.UserStoreProvider, gates onboarding.Gates) *OnboardingHandler {
return &OnboardingHandler{storeProvider: provider, gates: gates}
}
type onboardingStateResponse struct {
TourID string `json:"tour_id"`
LastStep string `json:"last_step,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
SkippedAt string `json:"skipped_at,omitempty"`
// Done is the one bit most clients need: show the tour or not.
Done bool `json:"done"`
}
type onboardingProgressRequest struct {
TourID string `json:"tour_id"`
LastStep string `json:"last_step"`
Completed bool `json:"completed"`
Skipped bool `json:"skipped"`
}
// HandleGetFlow handles GET /onboarding/flow?surface=web|phone|tv.
func (h *OnboardingHandler) HandleGetFlow(w http.ResponseWriter, r *http.Request) {
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
userID := apimw.GetUserID(r.Context())
surface := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("surface")))
switch surface {
case onboarding.SurfaceWeb, onboarding.SurfacePhone, onboarding.SurfaceTV:
case "":
surface = onboarding.SurfaceWeb
default:
writeError(w, http.StatusBadRequest, "bad_request", "surface must be web, phone, or tv")
return
}
isChild := false
if store, err := h.storeProvider.ForUser(r.Context(), userID); err == nil {
if profile, err := store.GetProfile(r.Context(), profileID); err == nil && profile != nil {
isChild = profile.IsChild
}
}
writeJSON(w, http.StatusOK, onboarding.FlowFor(r.Context(), h.gates, surface, isChild))
}
// HandleGetState handles GET /onboarding/state.
func (h *OnboardingHandler) HandleGetState(w http.ResponseWriter, r *http.Request) {
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
userID := apimw.GetUserID(r.Context())
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
state, err := store.GetOnboardingState(r.Context(), profileID, onboarding.TourID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read onboarding state")
return
}
resp := onboardingStateResponse{TourID: onboarding.TourID}
if state != nil {
resp.LastStep = state.LastStep
resp.CompletedAt = state.CompletedAt
resp.SkippedAt = state.SkippedAt
resp.Done = state.CompletedAt != "" || state.SkippedAt != ""
}
writeJSON(w, http.StatusOK, resp)
}
// HandlePostProgress handles POST /onboarding/progress.
func (h *OnboardingHandler) HandlePostProgress(w http.ResponseWriter, r *http.Request) {
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
userID := apimw.GetUserID(r.Context())
var req onboardingProgressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
// tour_id is accepted for forward compatibility but only the current
// tour is writable — a stale client can't corrupt a future tour's state.
if req.TourID != "" && req.TourID != onboarding.TourID {
writeError(w, http.StatusConflict, "tour_mismatch", "This tour is no longer current")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
state := userstore.OnboardingState{
ProfileID: profileID,
TourID: onboarding.TourID,
LastStep: req.LastStep,
}
now := time.Now().UTC().Format(time.RFC3339)
if req.Completed {
state.CompletedAt = now
}
if req.Skipped {
state.SkippedAt = now
}
if err := store.UpsertOnboardingState(r.Context(), state); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save onboarding progress")
return
}
w.WriteHeader(http.StatusNoContent)
}
+13 -100
View File
@@ -30,9 +30,6 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
@@ -1214,34 +1211,6 @@ func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *mod
return lang
}
// resolvedProfileAudioLanguage returns the effective playback.audio_language
// for the profile with no content context, resolved through the settings
// contract — the canonical replacement for reading the legacy
// user_profiles.language column, matching catalog's detail resolution. It may
// return playback.OriginalLanguageSentinel, which the caller resolves to a
// concrete language. Returns "" when nothing is stored: the contract default
// is null, "no preference".
func resolvedProfileAudioLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
if store == nil || profileID == "" {
return ""
}
contract, err := settingscontract.Load()
if err != nil {
return ""
}
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]string{settingskeys.PlaybackAudioLanguage}, nil)
if err != nil || len(resolved) == 0 {
return ""
}
var language string
if json.Unmarshal(resolved[0].Value, &language) != nil {
return ""
}
return strings.TrimSpace(language)
}
func (h *PlaybackHandler) restoreSessionProgress(
ctx context.Context,
session *playback.Session,
@@ -1746,7 +1715,9 @@ func (h *PlaybackHandler) handleStartPlaybackLegacy(w http.ResponseWriter, r *ht
if seriesPref != nil && seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
seriesPref.AudioLanguage = h.resolveOriginalLanguage(r.Context(), file)
}
preferredLang = resolvedProfileAudioLanguage(r.Context(), store, profileID)
if profile, profErr := store.GetProfile(r.Context(), profileID); profErr == nil && profile != nil {
preferredLang = profile.Language
}
// Resolve library override (if no series sticky pref exists).
var libraryAudioLang string
@@ -2638,12 +2609,8 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
}
// A v3 DV strip remux carries its bitstream filter in the
// durable session route; dropping it here would hand the node
// a DV7 copy recipe that leaves dangling RPUs. Sources that
// fail the RPU probe are the exception — for them the filter
// rejects every packet, so re-adding it on an audio switch
// would hang a session that was playing a moment ago.
if updatedSession.RemuxDVMode == playback.RemuxDVStripToHDR10V3 && strings.EqualFold(nodeReq.TargetCodecVideo, "copy") &&
playback.DVRPUStrippable(r.Context(), h.playbackConfig().FFmpegPath, file.FilePath) {
// a DV7 copy recipe that leaves dangling RPUs.
if updatedSession.RemuxDVMode == playback.RemuxDVStripToHDR10V3 && strings.EqualFold(nodeReq.TargetCodecVideo, "copy") {
nodeReq.VideoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
}
@@ -3133,21 +3100,10 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
// for "copy" has no way to know the source needs the strip. Derived after
// the burn-in guard above so a copy request it rewrites to h264 never carries
// a copy-only bitstream filter.
//
// Gated on the per-source probe for the same reason the planner is: a
// source whose RPU ffmpeg cannot parse turns the filter into a per-packet
// rejection that never produces a segment, and this endpoint would
// otherwise put it back on every quality change, seek and burn-in restart
// of a session the planner had already routed away from it.
videoBitstreamFilter := ""
if strings.EqualFold(req.TargetCodecVideo, "copy") &&
(session.RemuxDVMode == playback.RemuxDVStripToHDR10V3 || file.PrimaryDVProfile() == 7) {
if playback.DVRPUStrippable(r.Context(), h.playbackConfig().FFmpegPath, file.FilePath) {
videoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
} else {
slog.WarnContext(r.Context(), "restart dropped the dolby vision rpu strip: source cannot be stripped",
"component", "api", "playback_session_id", req.SessionID, "file_id", file.ID)
}
videoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
}
// The request-level permission check above intentionally runs before the
@@ -3178,9 +3134,6 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
switchedFileID = &alt.ID
}
}
if !videoCopy {
req.TargetResolution = clampEncodedTargetResolution(req.TargetResolution, file.Resolution)
}
if requestedFile != nil && file != nil && requestedFile.ID != file.ID {
if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) {
slog.WarnContext(r.Context(), "requested transcode file preflight failed; continuing with alternate file", "component", "api",
@@ -3968,60 +3921,20 @@ func (h *PlaybackHandler) findAlternateFile(ctx context.Context, source *models.
return candidates[0], nil
}
const (
transcodeResolution2160p = "2160p"
transcodeResolution1080p = "1080p"
transcodeResolution720p = "720p"
transcodeResolution480p = "480p"
transcodeResolution420p = "420p"
transcodeResolution328p = "328p"
)
// resolutionRank returns a numeric rank for resolution sorting.
func resolutionRank(res string) int {
height, known := transcodeResolutionHeight(res)
if !known {
return 0
}
switch {
case height >= 2160:
switch res {
case "2160p":
return 4
case height >= 1080:
case "1080p":
return 3
case height >= 720:
case "720p":
return 2
case height >= 480:
case "480p":
return 1
case "328p":
return 0
default:
return 0
}
}
func clampEncodedTargetResolution(requestedResolution, sourceResolution string) string {
requestedHeight, requestedKnown := transcodeResolutionHeight(requestedResolution)
sourceHeight, sourceKnown := transcodeResolutionHeight(sourceResolution)
if !requestedKnown || !sourceKnown || requestedHeight <= sourceHeight {
return requestedResolution
}
return sourceResolution
}
func transcodeResolutionHeight(resolution string) (int, bool) {
switch resolution {
case transcodeResolution2160p:
return 2160, true
case transcodeResolution1080p:
return 1080, true
case transcodeResolution720p:
return 720, true
case transcodeResolution480p:
return 480, true
case transcodeResolution420p:
return 420, true
case transcodeResolution328p:
return 328, true
default:
return 0, false
}
}
+5 -70
View File
@@ -74,7 +74,6 @@ type playbackSessionRow struct {
AudioDecision string `json:"audio_decision,omitempty"`
EffectivePlayMethod string `json:"effective_play_method,omitempty"`
IsJellyfinClient bool `json:"is_jellyfin_client,omitempty"`
CompatOrigin bool `json:"-"`
}
// playbackSessionsCapabilitiesResponse advertises the additive fields of the
@@ -200,8 +199,7 @@ func (l *PlaybackSessionsLoader) Load(
mf.audio_channels,
COALESCE(mf.audio_tracks::text, '[]'),
COALESCE(requested_mf.codec_video, ''),
COALESCE(requested_mf.resolution, ''),
COALESCE(s.compat_origin, FALSE)
COALESCE(requested_mf.resolution, '')
FROM playback_sessions_sync s
LEFT JOIN users u ON u.id = s.user_id
LEFT JOIN media_files mf ON mf.id = s.media_file_id
@@ -243,7 +241,6 @@ func (l *PlaybackSessionsLoader) Load(
&s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps,
&s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
&s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution,
&s.CompatOrigin,
); err != nil {
return nil, fmt.Errorf("scanning playback session: %w", err)
}
@@ -278,7 +275,7 @@ func enrichPlaybackSessionRow(row *playbackSessionRow, audioTracksJSON []byte) {
row.VideoDecision, row.AudioDecision = sessionComponentDecision(row.PlayMethod, row.TranscodeAudio, row.TargetVideoCodec)
row.EffectivePlayMethod = effectivePlayMethod(row.VideoDecision, row.AudioDecision)
row.IsJellyfinClient = row.CompatOrigin || isJellyfinEcosystemClient(row.ClientName, row.ClientUserAgent)
row.IsJellyfinClient = isJellyfinEcosystemClient(row.ClientName, row.ClientUserAgent)
var audioTracks []models.AudioTrack
if len(audioTracksJSON) > 0 {
@@ -421,8 +418,9 @@ var jellyfinClientTokens = []string{
}
// isJellyfinEcosystemClient reports whether the session's client metadata
// matches a known Jellyfin-ecosystem client. This heuristic remains a fallback
// for rows created before compat-origin identity was persisted.
// matches a known Jellyfin-ecosystem client. This is a heuristic: an
// unrecognized fork simply gets no JF pill (cosmetic). Stamping a compat-origin
// flag on the session at creation would be exact and is the eventual fix.
func isJellyfinEcosystemClient(clientName, userAgent string) bool {
for _, value := range []string{clientName, userAgent} {
value = strings.ToLower(strings.TrimSpace(value))
@@ -510,73 +508,10 @@ func playbackClientDisplayName(name, version, userAgent string) string {
case strings.Contains(lower, "python-requests"):
return "Python requests"
default:
if label := androidDeviceLabel(userAgent); label != "" {
return label
}
return firstUserAgentProduct(userAgent)
}
}
// knownAndroidDeviceLabels maps Android / Fire OS build model codes to friendly
// product names for the admin session view. Keys are uppercased so the lookup is
// case-insensitive. This only affects the displayed label — the session still
// stores the raw model code in its user agent.
var knownAndroidDeviceLabels = map[string]string{
"AFTKRT": "Fire TV Stick 4K Max",
"AFTMM": "Fire TV Stick 4K",
"AFTKM": "Fire TV Stick 4K (2nd Gen)",
"AFTKA": "Fire TV Stick 4K Max (1st Gen)",
"AFTSSS": "Fire TV Stick (3rd Gen)",
"AFTSS": "Fire TV Stick Lite (1st Gen)",
"AFTT": "Fire TV Stick (2nd Gen)",
"AFTB": "Fire TV (1st Gen)",
"AFTS": "Fire TV (2nd Gen)",
"AFTN": "Fire TV (3rd Gen)",
"AFTR": "Fire TV Cube (2nd Gen)",
"AFTA": "Fire TV Cube (1st Gen)",
"SHIELD ANDROID TV": "NVIDIA Shield",
}
// androidDeviceLabel derives a friendly device label from a bare Android / Fire OS
// user agent of the form "... (Linux; U; Android <ver>; <MODEL> Build/<build>)".
// The model is the whole segment between the last ';' and 'Build/', so multi-word
// models ("Pixel 7", "SHIELD Android TV") survive intact. Known model codes map to
// a product name; anything else falls back to "Android · <MODEL>" rather than the
// uninformative "Dalvik". Returns "" when no model can be parsed.
func androidDeviceLabel(userAgent string) string {
buildIndex := strings.Index(userAgent, "Build/")
if buildIndex < 0 {
return ""
}
prefix := userAgent[:buildIndex]
separator := strings.LastIndex(prefix, ";")
if separator < 0 {
return ""
}
hasAndroidPlatform := false
for _, segment := range strings.Split(prefix[:separator], ";") {
segment = strings.TrimSpace(strings.Trim(segment, "()"))
if strings.HasPrefix(strings.ToLower(segment), "android ") {
hasAndroidPlatform = true
break
}
}
if !hasAndroidPlatform {
return ""
}
model := prefix[separator+1:]
model = strings.TrimSpace(strings.Trim(strings.TrimSpace(model), "();"))
if model == "" {
return ""
}
if label, ok := knownAndroidDeviceLabels[strings.ToUpper(model)]; ok {
return label
}
return "Android · " + model
}
func containsAny(value string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
@@ -103,126 +103,3 @@ func TestIsJellyfinEcosystemClient(t *testing.T) {
})
}
}
func TestPlaybackClientDisplayNameAndroidDevices(t *testing.T) {
const curlClientLabel = "curl"
cases := []struct {
name string
userAgent string
want string
}{
{
name: "fire tv stick 4k max",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; AFTKRT Build/RS8180.3729N)",
want: "Fire TV Stick 4K Max",
},
{
name: "fire tv stick 4k",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTMM Build/PS7279)",
want: "Fire TV Stick 4K",
},
{
name: "fire tv stick 4k second generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; AFTKM Build/RS8139)",
want: "Fire TV Stick 4K (2nd Gen)",
},
{
name: "fire tv stick 4k max first generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTKA Build/PS7646)",
want: "Fire TV Stick 4K Max (1st Gen)",
},
{
name: "fire tv stick third generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTSSS Build/PS7279)",
want: "Fire TV Stick (3rd Gen)",
},
{
name: "fire tv stick lite first generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTSS Build/PS7279)",
want: "Fire TV Stick Lite (1st Gen)",
},
{
name: "fire tv stick second generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 5.1; AFTT Build/LVY48F)",
want: "Fire TV Stick (2nd Gen)",
},
{
name: "fire tv first generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 4.2; AFTB Build/JDQ39)",
want: "Fire TV (1st Gen)",
},
{
name: "fire tv second generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 5.1; AFTS Build/LVY48F)",
want: "Fire TV (2nd Gen)",
},
{
name: "fire tv third generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 7.1; AFTN Build/NS6265)",
want: "Fire TV (3rd Gen)",
},
{
name: "fire tv cube second generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTR Build/PS7646)",
want: "Fire TV Cube (2nd Gen)",
},
{
name: "fire tv cube first generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 7.1; AFTA Build/NS6265)",
want: "Fire TV Cube (1st Gen)",
},
{
name: "unmapped multi-word android model preserved",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A)",
want: "Android · Pixel 7",
},
{
name: "shield model",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A)",
want: "NVIDIA Shield",
},
{
name: "chrome remains browser label",
userAgent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36",
want: "Chrome 120",
},
{
name: "non-android build user agent keeps existing fallback",
userAgent: "curl/8.0 (Linux; Device Build/42)",
want: curlClientLabel,
},
{
name: "explicit client fallback wins over android device model",
userAgent: "curl/8.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A)",
want: curlClientLabel,
},
{
name: "android substring is not an android platform token",
userAgent: "Dalvik/2.1.0 (Linux; U; NotAndroid 13; Pixel 7 Build/TQ3A)",
want: "Dalvik",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := playbackClientDisplayName("", "", tc.userAgent)
if got != tc.want {
t.Fatalf("playbackClientDisplayName() = %q, want %q", got, tc.want)
}
})
}
}
func TestEnrichPlaybackSessionRowUsesCompatOrigin(t *testing.T) {
row := playbackSessionRow{
ClientName: "Unrecognized Client",
ClientUserAgent: "Dalvik/2.1.0",
CompatOrigin: true,
}
enrichPlaybackSessionRow(&row, nil)
if !row.IsJellyfinClient {
t.Fatal("compat-origin session must be marked as a Jellyfin client")
}
}
+3 -240
View File
@@ -28,8 +28,6 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/transcodenode"
"github.com/Silo-Server/silo-server/internal/userdb"
@@ -695,86 +693,6 @@ func TestHandleStartPlayback_DoesNotPersistSeriesPlaybackPreferenceOnFailure(t *
}
}
func TestHandleStartPlayback_AudioLanguageResolvesCanonically(t *testing.T) {
// The default audio track comes from the canonical playback.audio_language
// value resolved through the settings contract, not from the legacy
// user_profiles.language column. The column always carries the language of
// a different track than the canonical answer, so a regression to reading
// it flips the selected index.
newFile := func(t *testing.T) *models.MediaFile {
return &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: writePlaybackTestMediaFile(t, "movie.mkv"),
Duration: 3600,
AudioTracks: []models.AudioTrack{
{Language: "eng", Codec: "aac", Default: true},
{Language: "jpn", Codec: "aac"},
},
}
}
setLegacyLanguage := func(t *testing.T, store userstore.UserStore, language string) {
t.Helper()
if err := store.UpdateProfile(context.Background(), "profile-1", userstore.UpdateProfileInput{
Language: &language,
}); err != nil {
t.Fatalf("seed legacy language column: %v", err)
}
}
startPlayback := func(t *testing.T, store userstore.UserStore, file *models.MediaFile) playbackSessionResponse {
t.Helper()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file})
handler.StoreProvider = testUserStoreProvider{store: store}
handler.ItemAccess = allowAllPlaybackItemAccess{}
req := httptest.NewRequest("POST", "/api/v1/playback/start",
strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct"}`))
req = req.WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp playbackSessionResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
return resp
}
t.Run("canonical value wins over legacy column", func(t *testing.T) {
store := newPlaybackTestStore(t)
setLegacyLanguage(t, store, "eng")
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(`"ja"`)); err != nil {
t.Fatalf("seed canonical audio language: %v", err)
}
resp := startPlayback(t, store, newFile(t))
if resp.AudioTrackIndex != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (canonical \"ja\" track)", resp.AudioTrackIndex)
}
})
t.Run("legacy column alone no longer selects a track", func(t *testing.T) {
store := newPlaybackTestStore(t)
setLegacyLanguage(t, store, "jpn")
resp := startPlayback(t, store, newFile(t))
// No canonical value stored: the contract default is "no preference",
// so selection falls to the file's default track, not the column's.
if resp.AudioTrackIndex != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (file default track)", resp.AudioTrackIndex)
}
})
}
func TestHandleChangeAudioTrack_PersistsSeriesAudioPreferenceSignature(t *testing.T) {
store := newPlaybackTestStore(t)
file := &models.MediaFile{
@@ -1425,7 +1343,7 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
transcodeReq := httptest.NewRequest(
"POST",
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"2160p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":10000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
)
transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext())
@@ -1441,12 +1359,6 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
if remoteStartReq.AudioTrackIndex != 1 {
t.Fatalf("remote audio_track_index = %d, want 1", remoteStartReq.AudioTrackIndex)
}
if remoteStartReq.TargetResolution != transcodeResolution1080p {
t.Fatalf("remote target resolution = %q, want 1080p", remoteStartReq.TargetResolution)
}
if remoteStartReq.TargetBitrateKbps != 10000 {
t.Fatalf("remote target bitrate = %d, want unchanged 10000", remoteStartReq.TargetBitrateKbps)
}
session, err := sessionMgr.GetSession(startResp.SessionID)
if err != nil {
@@ -1458,12 +1370,6 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
if session.BasePlayMethod != playback.PlayRemux {
t.Fatalf("session.BasePlayMethod = %q, want %q", session.BasePlayMethod, playback.PlayRemux)
}
if session.TargetResolution != transcodeResolution1080p {
t.Fatalf("session target resolution = %q, want 1080p", session.TargetResolution)
}
if session.TargetBitrateKbps != 10000 {
t.Fatalf("session target bitrate = %d, want unchanged 10000", session.TargetBitrateKbps)
}
}
// newRemoteAudioSwitchSession builds a session that is already an offloaded
@@ -2104,7 +2010,7 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
transcodeReq := httptest.NewRequest(
"POST",
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"2160p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":10000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
)
transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext())
@@ -2124,22 +2030,6 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
if got := transcodeSession.Opts().AudioTrackIndex; got != 1 {
t.Fatalf("local transcode audio track index = %d, want 1", got)
}
if got := transcodeSession.Opts().TargetResolution; got != transcodeResolution1080p {
t.Fatalf("local target resolution = %q, want 1080p", got)
}
if got := transcodeSession.Opts().TargetBitrateKbps; got != 10000 {
t.Fatalf("local target bitrate = %d, want unchanged 10000", got)
}
session, err := sessionMgr.GetSession(startResp.SessionID)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if session.TargetResolution != transcodeResolution1080p {
t.Fatalf("session target resolution = %q, want 1080p", session.TargetResolution)
}
if session.TargetBitrateKbps != 10000 {
t.Fatalf("session target bitrate = %d, want unchanged 10000", session.TargetBitrateKbps)
}
}
func TestHandleChangeAudioTrack_LocalCopyRestartUsesFreshSeekAnchor(t *testing.T) {
@@ -2510,7 +2400,7 @@ func TestHandleStartTranscode_SeekedCopyPreservedOnRemoteNode(t *testing.T) {
transcodeReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"2160p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
).WithContext(newAuthorizedPlaybackContext())
transcodeRR := httptest.NewRecorder()
handler.HandleStartTranscode(transcodeRR, transcodeReq)
@@ -2521,9 +2411,6 @@ func TestHandleStartTranscode_SeekedCopyPreservedOnRemoteNode(t *testing.T) {
remoteStartReq.StreamOriginSeconds != 16 || !remoteStartReq.CopySeekAnchorResolved || remoteStartReq.StartSegmentNumber != 8 {
t.Fatalf("remote copy recipe = codec %q seek %v origin %v segment %d", remoteStartReq.TargetCodecVideo, remoteStartReq.SeekSeconds, remoteStartReq.StreamOriginSeconds, remoteStartReq.StartSegmentNumber)
}
if remoteStartReq.TargetResolution != transcodeResolution2160p {
t.Fatalf("remote copy target resolution = %q, want unchanged 2160p", remoteStartReq.TargetResolution)
}
if !strings.HasPrefix(remoteStartReq.SessionID, session.ID+legacyTransportMarker) {
t.Fatalf("remote SessionID = %q, want legacy transport for %q", remoteStartReq.SessionID, session.ID)
}
@@ -3017,9 +2904,6 @@ func TestHandleStartTranscode_SeekedCopyAllowedWhenVideoTranscodingDisabled(t *t
if got := transcodeSession.Opts().TargetCodecVideo; got != "copy" {
t.Fatalf("target video codec = %q, want copy", got)
}
if got := transcodeSession.Opts().TargetResolution; got != "" {
t.Fatalf("copy-video target resolution = %q, want existing empty recipe", got)
}
}
func TestHandleStartTranscode_SubtitleBurnInRechecksVideoTranscodePermission(t *testing.T) {
@@ -3485,127 +3369,6 @@ func TestFindAlternateFile_DoesNotCrossEdition(t *testing.T) {
}
}
func TestClampEncodedTargetResolution(t *testing.T) {
tests := []struct {
name string
requested string
source string
want string
}{
{
name: "clamps 2160p to 1080p",
requested: transcodeResolution2160p,
source: transcodeResolution1080p,
want: transcodeResolution1080p,
},
{
name: "keeps lower target",
requested: transcodeResolution720p,
source: transcodeResolution1080p,
want: transcodeResolution720p,
},
{
name: "keeps equal target",
requested: transcodeResolution1080p,
source: transcodeResolution1080p,
want: transcodeResolution1080p,
},
{
name: "keeps empty target",
requested: "",
source: transcodeResolution1080p,
want: "",
},
{
name: "keeps unknown target",
requested: "unrecognized-target",
source: transcodeResolution1080p,
want: "unrecognized-target",
},
{
name: "keeps target for unknown source",
requested: transcodeResolution2160p,
source: "native",
want: transcodeResolution2160p,
},
{
name: "supports low tiers",
requested: transcodeResolution480p,
source: transcodeResolution420p,
want: transcodeResolution420p,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := clampEncodedTargetResolution(tt.requested, tt.source); got != tt.want {
t.Fatalf(
"clampEncodedTargetResolution(%q, %q) = %q, want %q",
tt.requested,
tt.source,
got,
tt.want,
)
}
})
}
}
func TestTranscodeResolutionHeight(t *testing.T) {
tests := []struct {
resolution string
wantHeight int
wantKnown bool
}{
{resolution: transcodeResolution2160p, wantHeight: 2160, wantKnown: true},
{resolution: transcodeResolution1080p, wantHeight: 1080, wantKnown: true},
{resolution: transcodeResolution720p, wantHeight: 720, wantKnown: true},
{resolution: transcodeResolution480p, wantHeight: 480, wantKnown: true},
{resolution: transcodeResolution420p, wantHeight: 420, wantKnown: true},
{resolution: transcodeResolution328p, wantHeight: 328, wantKnown: true},
{resolution: "unrecognized-tier", wantHeight: 0, wantKnown: false},
}
for _, tt := range tests {
t.Run(tt.resolution, func(t *testing.T) {
gotHeight, gotKnown := transcodeResolutionHeight(tt.resolution)
if gotHeight != tt.wantHeight || gotKnown != tt.wantKnown {
t.Fatalf(
"transcodeResolutionHeight(%q) = (%d, %t), want (%d, %t)",
tt.resolution,
gotHeight,
gotKnown,
tt.wantHeight,
tt.wantKnown,
)
}
})
}
}
func TestResolutionRank(t *testing.T) {
tests := []struct {
resolution string
want int
}{
{resolution: transcodeResolution2160p, want: 4},
{resolution: transcodeResolution1080p, want: 3},
{resolution: transcodeResolution720p, want: 2},
{resolution: transcodeResolution480p, want: 1},
{resolution: transcodeResolution420p, want: 0},
{resolution: transcodeResolution328p, want: 0},
{resolution: "unrecognized-tier", want: 0},
}
for _, tt := range tests {
t.Run(tt.resolution, func(t *testing.T) {
if got := resolutionRank(tt.resolution); got != tt.want {
t.Fatalf("resolutionRank(%q) = %d, want %d", tt.resolution, got, tt.want)
}
})
}
}
func TestAlignedSeekSeconds(t *testing.T) {
tests := []struct {
name string
+10 -40
View File
@@ -425,7 +425,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
Request: req, RequestedFile: requestedFile, EffectiveFile: effectiveFile,
AudioTrackIndex: audioIndex, Settings: settings,
Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(),
Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), Now: time.Now(),
AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile),
})
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && shouldTryAlternateFileV3(req.QualityPreference) {
@@ -440,7 +440,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
writePlaybackFilePreflightError(w, err)
return
}
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: req, RequestedFile: requestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: req, RequestedFile: requestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
}
}
if result.Terminal != nil {
@@ -1212,7 +1212,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
attemptedKeys = append(attemptedKeys, currentKey)
}
}
result := playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
result := playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && replanAllowsAlternateFileV3(operation, start.QualityPreference) {
if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil {
alternate = h.ensurePlaybackProbe(r.Context(), alternate)
@@ -1222,7 +1222,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
if err := preflightPlaybackFile(r.Context(), alternate, h.MissingMarker, h.EventsHub); err == nil {
effectiveFile = alternate
audioIndex = remappedAudio
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
}
}
}
@@ -1990,29 +1990,6 @@ func videoBitstreamFilterForPlanV3(plan *playback.PlanV3) string {
return ""
}
// lazyDVRPUStrippableV3 defers (and memoizes) the per-source RPU probe so the
// planner only shells out to ffmpeg when a Dolby Vision strip route is
// genuinely on the table; every other start never touches it.
//
// The probe belongs to planning, not to the transport: the plan's HDR10 promise
// and the durable session's RemuxDVMode are both derived from the strip
// decision and are re-read by the restart and audio-switch paths, so
// suppressing the filter downstream would leave those claims describing a
// stream the server is no longer producing.
func (h *PlaybackHandler) lazyDVRPUStrippableV3(ctx context.Context, file *models.MediaFile) func() bool {
if file == nil || strings.TrimSpace(file.FilePath) == "" {
return nil
}
var once sync.Once
strippable := true
return func() bool {
once.Do(func() {
strippable = playback.DVRPUStrippable(ctx, h.playbackConfig().FFmpegPath, file.FilePath)
})
return strippable
}
}
func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDuration int, durationSeconds float64) (float64, int) {
if plan == nil {
return 0, 0
@@ -2027,19 +2004,12 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
plan.Timeline.TimelineOffsetSeconds = seek
windowStart := seek
plan.Timeline.SeekWindowStartSeconds = &windowStart
// A copy remux is served from FFmpeg's live, still-growing playlist
// (see BuildPlaybackManifest), so the seekable extent is whatever has
// been produced so far — a value this plan cannot know and could not
// keep current if it did. Publishing the media runtime here instead
// made the window look *complete*, which clients read as proof that
// any target inside it is locally seekable; they then native-seek past
// the produced head instead of asking for a reanchor. Leaving the end
// open marks the window incomplete, which with can_seek_anywhere=false
// routes every seek back through the server.
//
// The media runtime is published on source.duration_seconds, which is
// a fact about the file rather than a claim about this transport.
plan.Timeline.SeekWindowEndSeconds = nil
if durationSeconds > 0 {
windowEnd := durationSeconds
plan.Timeline.SeekWindowEndSeconds = &windowEnd
} else {
plan.Timeline.SeekWindowEndSeconds = nil
}
plan.Timeline.CanSeekAnywhere = false
plan.Timeline.SeekRestoration = "source_position"
} else {
+1 -22
View File
@@ -606,22 +606,6 @@ func TestHandleReplanPlaybackV3SeekReanchorKeepsCurrentRecipeEligible(t *testing
}
func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *testing.T) {
// This test has never passed. It fails at 854d07cf, the commit that
// introduced it, so it describes behavior that was specified and not
// implemented rather than behavior that regressed.
//
// What it asks for: when a seek fails and the client's replan capabilities
// have narrowed to 1080p, recovery must stay on the pinned 4K media version
// and must not video-transcode it. Today the planner takes the narrowed
// per-request capabilities at face value, finds the 4K source unplayable
// with allow_4k_transcode disabled, and answers adaptation_unavailable.
//
// Making it pass means deciding whether replan capabilities may narrow
// media-version selection at all, which is a protocol v3 planner change and
// does not belong to whichever change happens to notice the failure. Skipped
// rather than excluded in the Makefile so the reason travels with the test.
t.Skip("specifies unimplemented v3 planner behavior; see the comment above")
source := v3HandlerFixtureFile(t)
source.Resolution = "2160p"
source.Bitrate = 32_000
@@ -1211,16 +1195,11 @@ func TestHandleStartPlaybackLegacyBranchPreservesTrailingBodyBehavior(t *testing
}
func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
// A copy remux streams FFmpeg's growing playlist, so its seek window must
// stay open-ended even though the runtime is known. A bounded window reads
// as "complete", which clients treat as proof that any target inside it is
// locally seekable — sending them past the produced head instead of back
// to the server. The runtime belongs on source.duration_seconds.
copyPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
copySeek, copySegment := configureHLSTimelineV3(copyPlan, "copy", 2, 600)
if copySeek != 17.3 || copySegment != 8 || copyPlan.Timeline.StreamOriginSeconds != 17.3 || copyPlan.Timeline.TimelineOffsetSeconds != 17.3 || copyPlan.Timeline.PlayerStartSeconds != 0 || copyPlan.Timeline.CanSeekAnywhere ||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 17.3 ||
copyPlan.Timeline.SeekWindowEndSeconds != nil ||
copyPlan.Timeline.SeekWindowEndSeconds == nil || *copyPlan.Timeline.SeekWindowEndSeconds != 600 ||
copyPlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("copy timeline=%#v seek=%v segment=%d", copyPlan.Timeline, copySeek, copySegment)
}
-2
View File
@@ -855,8 +855,6 @@ func uploadErrorResponse(err error) (int, string) {
return http.StatusConflict, "Upload session is incomplete"
case errors.Is(err, uploads.ErrAlreadyCompleted):
return http.StatusConflict, "Upload session is already complete"
case errors.Is(err, uploads.ErrChunkBusy):
return http.StatusConflict, "This chunk is already being uploaded"
case errors.Is(err, uploads.ErrInvalidChunk), errors.Is(err, uploads.ErrInvalidRequest):
return http.StatusBadRequest, err.Error()
default:
+2 -2
View File
@@ -284,7 +284,7 @@ func (h *ProfileHandler) HandleUploadAvatar(w http.ResponseWriter, r *http.Reque
return
}
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), store, *updatedProfile))
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *updatedProfile))
}
func (h *ProfileHandler) HandleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
@@ -326,5 +326,5 @@ func (h *ProfileHandler) HandleDeleteAvatar(w http.ResponseWriter, r *http.Reque
return
}
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), store, *updatedProfile))
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *updatedProfile))
}
+83 -92
View File
@@ -15,7 +15,6 @@ import (
"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"
)
@@ -36,10 +35,6 @@ type ProfileHandler struct {
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.
@@ -137,24 +132,78 @@ type verifyPINResponse struct {
}
// canManageHouseholdProfiles reports whether the caller may create/update/delete
// profiles belonging to their user.
// profiles belonging to their user. Server admins always can. Otherwise the
// caller's active profile must be the primary profile for the household.
//
// 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.
// When that primary profile has a PIN, management also requires a valid
// X-Profile-Token from `/profiles/{id}/verify-pin`; otherwise a client could
// bypass the profile lock by sending only X-Profile-Id.
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
return canManageHousehold(r, store, h.userLookupOrNil(), h.ProfileTokens)
ctx := r.Context()
if apimw.IsAdmin(ctx) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
if activeProfileID == "" {
activeProfileID = r.Header.Get("X-Profile-Id")
}
if activeProfileID == "" {
return false, nil
}
active, err := store.GetProfile(ctx, activeProfileID)
if err != nil {
return false, err
}
if active == nil {
return false, nil
}
if !active.IsPrimary {
return false, nil
}
if active.PINHash == "" {
return true, nil
}
if err := h.requireVerifiedProfileToken(r, active.ID); err != nil {
return false, err
}
return true, nil
}
// 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
func (h *ProfileHandler) requireVerifiedProfileToken(r *http.Request, profileID string) error {
if h.UserRepo == nil || h.ProfileTokens == nil {
return access.ErrProfileUnverified
}
return h.UserRepo
claims := apimw.GetClaims(r.Context())
if claims == nil || claims.SessionID == "" {
return access.ErrProfileUnverified
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
return access.ErrProfileUnverified
}
user, err := h.UserRepo.GetByID(r.Context(), userID)
if err != nil {
return fmt.Errorf("loading user policy: %w", err)
}
if user == nil {
return access.ErrProfileUnverified
}
profileClaims, err := h.ProfileTokens.Validate(r.Header.Get("X-Profile-Token"))
if err != nil {
return err
}
if profileClaims.UserID != userID ||
profileClaims.SessionID != claims.SessionID ||
profileClaims.ProfileID != profileID ||
profileClaims.PolicyRevision != user.AccessPolicyRevision {
return access.ErrProfileUnverified
}
return nil
}
func writeProfileManagementPermissionError(w http.ResponseWriter, err error) {
@@ -226,9 +275,12 @@ func (h *ProfileHandler) HandleListProfiles(w http.ResponseWriter, r *http.Reque
}
resp := profileListResponse{
Profiles: h.toProfileResponses(r.Context(), store, profiles),
Profiles: make([]profileResponse, 0, len(profiles)),
AvatarUploadEnabled: h.AvatarStore != nil,
}
for _, p := range profiles {
resp.Profiles = append(resp.Profiles, h.toProfileResponse(r.Context(), p))
}
writeJSON(w, http.StatusOK, resp)
}
@@ -263,14 +315,6 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
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")
@@ -368,10 +412,8 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
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")
if err := store.CreateProfile(r.Context(), profile); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create profile")
return
}
@@ -414,7 +456,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
created = *p
}
writeJSON(w, http.StatusCreated, h.toProfileResponse(r.Context(), store, created))
writeJSON(w, http.StatusCreated, h.toProfileResponse(r.Context(), created))
}
// HandleUpdateProfile handles PUT /profiles/{id}.
@@ -524,14 +566,6 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
}
}
// 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,
@@ -553,15 +587,8 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
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")
if err := store.UpdateProfile(r.Context(), profileID, input); err != nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
return
}
if currentProfile.Avatar != "" && avatarRef != nil && avatarRefReplacesUpload(currentProfile.Avatar, *avatarRef) {
@@ -577,7 +604,7 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
return
}
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), store, *profile))
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *profile))
}
// HandleDeleteProfile handles DELETE /profiles/{id}.
@@ -720,43 +747,7 @@ func (h *ProfileHandler) HandleVerifyPIN(w http.ResponseWriter, r *http.Request)
// --- 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 {
func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Profile) profileResponse {
avatarSource, avatarURL := resolveProfileAvatar(ctx, h.AvatarStore, h.AvatarTTL, p.Avatar)
return profileResponse{
ID: p.ID,
@@ -769,15 +760,15 @@ func (h *ProfileHandler) profileResponseWith(
IsPrimary: p.IsPrimary,
MaxContentRating: p.MaxContentRating,
QualityPreference: p.QualityPreference,
Language: prefs.AudioLanguage,
PreferredMetadataLanguage: prefs.MetadataLanguage,
SubtitleLanguage: prefs.SubtitleLanguage,
SubtitleMode: prefs.SubtitleMode,
Language: p.Language,
PreferredMetadataLanguage: p.PreferredMetadataLanguage,
SubtitleLanguage: p.SubtitleLanguage,
SubtitleMode: p.SubtitleMode,
AutoSkipIntro: p.AutoSkipIntro,
AutoSkipCredits: p.AutoSkipCredits,
AutoSkipRecap: p.AutoSkipRecap,
AutoPlayNextPreview: p.AutoPlayNextPreview,
ShowForcedSubtitles: prefs.ShowForcedSubtitles,
ShowForcedSubtitles: p.ShowForcedSubtitles,
LibraryRestrictionsEnabled: p.LibraryRestrictionsEnabled,
AllowedLibraryIDs: append([]int(nil), p.AllowedLibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(p.MaxPlaybackQuality),
@@ -1,435 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// The legacy profile endpoints are still the write path shipped clients use
// for the preference columns, but every server-side reader of those
// preferences now resolves them canonically from user_setting_values:
// access.Resolver and policy.ViewerResolver for catalog.metadata_language,
// playback start and catalog detail for the playback.* preferences. The
// settings backfill runs once, so a column write that never reaches the
// canonical store simply never takes effect — the stale backfilled row, or
// the contract default, wins forever.
//
// Until the clients move to /settings/values, every profile create or update
// therefore mirrors its preference fields into the profile-scope canonical
// rows the readers consult. The mapping is the live-write counterpart of
// settingsmigrate.planProfiles with one deliberate difference: the migration
// skips a column still holding its schema default because it cannot tell
// "never decided" from "chose the default", while a live request names the
// field explicitly, so its value — default or not — is a real choice and is
// stored.
//
// quality_preference is deliberately not mirrored: the server never resolves
// the legacy column (playback requests carry the quality preference
// per-request), and the two-axis quality picker already writes
// playback.preferred_quality and playback.max_bitrate_kbps through
// /settings/values directly.
// profileSettingSync is one canonical write implied by a legacy profile
// mutation. A nil value clears the profile-scope row so resolution falls
// back to the contract default, which is how the legacy empty string spells
// "no preference".
type profileSettingSync struct {
key string
value json.RawMessage
}
// planCreateProfileSettingsSync plans the canonical writes for POST
// /profiles. Create requests carry plain strings, so an absent field arrives
// as "" and plans a no-op delete against the freshly created profile.
func planCreateProfileSettingsSync(req createProfileRequest) ([]profileSettingSync, error) {
return planProfileSettingsSync(
&req.Language, &req.SubtitleLanguage, &req.PreferredMetadataLanguage,
&req.SubtitleMode, req.ShowForcedSubtitles,
profileSkipFields{
autoSkipIntro: &req.AutoSkipIntro,
autoSkipCredits: &req.AutoSkipCredits,
autoSkipRecap: &req.AutoSkipRecap,
autoPlayNextPreview: &req.AutoPlayNextPreview,
})
}
// planUpdateProfileSettingsSync plans the canonical writes for PUT
// /profiles/{id}. A nil field was not part of the request and must not touch
// the canonical row; the shipped clients send single-field deltas.
func planUpdateProfileSettingsSync(req updateProfileRequest) ([]profileSettingSync, error) {
return planProfileSettingsSync(
req.Language, req.SubtitleLanguage, req.PreferredMetadataLanguage,
req.SubtitleMode, req.ShowForcedSubtitles,
profileSkipFields{
autoSkipIntro: req.AutoSkipIntro,
autoSkipCredits: req.AutoSkipCredits,
autoSkipRecap: req.AutoSkipRecap,
autoPlayNextPreview: req.AutoPlayNextPreview,
})
}
// profileSkipFields groups the four boolean playback toggles the profile DTO
// carries. They travel together because they behave identically: a nil field
// was not in the request, and a present one mirrors verbatim.
type profileSkipFields struct {
autoSkipIntro *bool
autoSkipCredits *bool
autoSkipRecap *bool
autoPlayNextPreview *bool
}
func planProfileSettingsSync(
audioLang, subtitleLang, metadataLang, subtitleMode *string,
showForced *bool,
skips profileSkipFields,
) ([]profileSettingSync, error) {
var out []profileSettingSync
var err error
for _, field := range []struct {
key string
raw *string
}{
{settingskeys.PlaybackAudioLanguage, audioLang},
{settingskeys.PlaybackSubtitleLanguage, subtitleLang},
{settingskeys.CatalogMetadataLanguage, metadataLang},
{settingskeys.PlaybackSubtitleMode, subtitleMode},
} {
if out, err = appendStringSync(out, field.key, field.raw); err != nil {
return nil, err
}
}
// The booleans have no "unset" spelling on the wire — the legacy columns
// are NOT NULL — so a present field always writes an explicit value.
for _, field := range []struct {
key string
raw *bool
}{
{settingskeys.PlaybackShowForcedSubtitles, showForced},
{settingskeys.PlaybackAutoSkipIntro, skips.autoSkipIntro},
{settingskeys.PlaybackAutoSkipCredits, skips.autoSkipCredits},
{settingskeys.PlaybackAutoSkipRecap, skips.autoSkipRecap},
{settingskeys.PlaybackAutoPlayNextPreview, skips.autoPlayNextPreview},
} {
if field.raw == nil {
continue
}
out = append(out, profileSettingSync{
key: field.key,
value: json.RawMessage(strconv.FormatBool(*field.raw)),
})
}
return out, nil
}
// appendStringSync plans one string-valued column. The empty string is the
// legacy spelling of "unset" for both the language columns and subtitle_mode,
// so it clears the canonical row; anything else must normalize under the
// contract — the same check /settings/values applies — so nothing reaches
// storage that the canonical endpoint would refuse, and an invalid value is
// reported instead of silently never taking effect.
func appendStringSync(out []profileSettingSync, key string, raw *string) ([]profileSettingSync, error) {
if raw == nil {
return out, nil
}
trimmed := strings.TrimSpace(*raw)
if trimmed == "" {
return append(out, profileSettingSync{key: key}), nil
}
encoded, err := json.Marshal(trimmed)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
normalized, err := normalizeCanonicalSettingValue(key, encoded)
if err != nil {
return nil, err
}
return append(out, profileSettingSync{key: key, value: normalized}), nil
}
// normalizeCanonicalSettingValue runs a planned value through the same
// contract validation the canonical mutation endpoint uses.
func normalizeCanonicalSettingValue(key string, raw json.RawMessage) (json.RawMessage, error) {
contract, err := settingscontract.Load()
if err != nil {
return nil, fmt.Errorf("loading the settings contract: %w", err)
}
def, ok := contract.Lookup(key)
if !ok {
return nil, fmt.Errorf("%s has no contract definition", key)
}
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
return normalized, nil
}
// createProfileWithSettingsSync creates the profile, snapshots surviving
// account-wide legacy settings, and writes every canonical row in one store
// transaction. PostgreSQL's transaction wrapper also holds a per-user
// advisory lock shared with legacy account-setting fan-out, closing the
// cross-replica create/write race.
func (h *ProfileHandler) createProfileWithSettingsSync(
ctx context.Context,
store userstore.UserStore,
userID int,
profile userstore.Profile,
writes []profileSettingSync,
) error {
transactioner, ok := store.(userstore.PreferenceSettingsTransactioner)
if !ok {
return fmt.Errorf("user store does not support atomic preference settings synchronization")
}
var changedKeys []string
err := transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
if err := tx.CreateProfile(ctx, profile); err != nil {
return err
}
inherited, err := planInheritedLegacyUserSettings(ctx, tx)
if err != nil {
return err
}
changedKeys, err = writeCanonicalSettingsSync(ctx, tx, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfile, ProfileID: profile.ID,
}, append(writes, inherited...))
return err
})
if err != nil {
return err
}
for _, key := range changedKeys {
publishUserSettingsEvent(ctx, h.EventsHub, userID, profile.ID, key, string(settingscontract.ScopeProfile))
}
return nil
}
func (h *ProfileHandler) applyProfileUpdateSettingsSync(
ctx context.Context,
store userstore.UserStore,
userID int,
profileID string,
input userstore.UpdateProfileInput,
writes []profileSettingSync,
) error {
return applyLegacyPreferenceSettingsSync(ctx, store, h.EventsHub, userID, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfile, ProfileID: profileID,
}, writes, func(tx userstore.PreferenceSettingsWriter) error {
return tx.UpdateProfile(ctx, profileID, input)
})
}
// applyLegacyPreferenceSettingsSync is the live-write counterpart of the
// migration planner for legacy preference endpoints. The legacy mutation and
// every canonical row commit in one store transaction; events are deliberately
// published afterwards so subscribers can never observe uncommitted state.
func applyLegacyPreferenceSettingsSync(
ctx context.Context,
store userstore.UserStore,
events *evt.Hub,
userID int,
base userstore.SettingIdentity,
writes []profileSettingSync,
legacyMutation func(userstore.PreferenceSettingsWriter) error,
) error {
var changedKeys []string
transactioner, ok := store.(userstore.PreferenceSettingsTransactioner)
if !ok {
return fmt.Errorf("user store does not support atomic preference settings synchronization")
}
err := transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
if err := legacyMutation(tx); err != nil {
return err
}
var err error
changedKeys, err = writeCanonicalSettingsSync(ctx, tx, base, writes)
return err
})
if err != nil {
return err
}
for _, key := range changedKeys {
publishUserSettingsEvent(ctx, events, userID, base.ProfileID, key, string(base.Scope))
}
return nil
}
func writeCanonicalSettingsSync(
ctx context.Context,
store userstore.PreferenceSettingsWriter,
base userstore.SettingIdentity,
writes []profileSettingSync,
) ([]string, error) {
changedKeys := make([]string, 0, len(writes))
for _, write := range writes {
identity := base
identity.Key = write.key
if write.value == nil {
removed, err := store.DeleteSettingValue(ctx, identity)
if err != nil {
return nil, fmt.Errorf("clearing %s: %w", write.key, err)
}
if !removed {
continue // nothing was stored, so nothing changed
}
} else if _, err := store.UpsertSettingValue(ctx, identity, write.value); err != nil {
return nil, fmt.Errorf("storing %s: %w", write.key, err)
}
changedKeys = append(changedKeys, write.key)
}
return changedKeys, nil
}
// --- Read side ---
//
// The profile DTO's preference fields are served from the same canonical rows
// the sync above writes, not from the legacy columns. Without this, a
// preference saved through PUT /settings/values lands in user_setting_values
// and is invisible in every profile DTO reader on every platform: the columns
// only move when a client goes through POST/PUT /profiles, and the cutover
// direction is that they stop being read rather than start being dual-written.
//
// The fallback is the contract default, never the column. A column holding a
// pre-cutover value that the one-time backfill already converted would
// otherwise resurface the moment its canonical row is unset — the "clear this
// preference" path would read as "restore the value from before the cutover".
// profilePreferences is the resolved form of the DTO's preference block. Each
// field is the effective value for one profile, already defaulted, so the
// serializer copies rather than decides.
type profilePreferences struct {
AudioLanguage string
MetadataLanguage string
SubtitleLanguage string
SubtitleMode string
ShowForcedSubtitles bool
}
// profilePreferenceKeys are the canonical keys behind the DTO's preference
// fields, in DTO field order.
//
// quality_preference has no entry: the legacy column is a single compound
// value while the contract splits it across playback.preferred_quality and
// playback.max_bitrate_kbps, so there is no lossless read and the field stays
// column-backed. The auto_skip_* and auto_play_next_preview fields do sync on
// write, but this list drives the DTO's read block, whose shape the clients
// pin; they keep reading their columns, which the sync now keeps current.
var profilePreferenceKeys = []string{
settingskeys.PlaybackAudioLanguage,
settingskeys.CatalogMetadataLanguage,
settingskeys.PlaybackSubtitleLanguage,
settingskeys.PlaybackSubtitleMode,
settingskeys.PlaybackShowForcedSubtitles,
}
// resolveProfilePreferences resolves the preference block for every listed
// profile in one store read.
//
// One read for the whole household rather than one per profile: GET /profiles
// serves several profiles and this is on its hot path. A resolution failure
// degrades to contract defaults rather than failing the request — these are
// presentation preferences, not an access boundary — but it is logged, because
// a store outage that silently hands every profile the defaults is otherwise
// indistinguishable from a household that never set anything.
func resolveProfilePreferences(
ctx context.Context,
store userstore.UserStore,
profileIDs []string,
) map[string]profilePreferences {
defaults := contractProfilePreferences()
out := make(map[string]profilePreferences, len(profileIDs))
for _, id := range profileIDs {
out[id] = defaults
}
if store == nil || len(profileIDs) == 0 {
return out
}
contract, err := settingscontract.Load()
if err != nil {
slog.WarnContext(ctx, "profile preferences degraded to contract defaults: loading settings contract failed",
"component", "api", "error", err)
return out
}
resolved, err := settingsresolve.New(contract).ResolveProfiles(
ctx, store, profileIDs, profilePreferenceKeys, nil)
if err != nil {
slog.WarnContext(ctx, "profile preferences degraded to contract defaults: reading setting values failed",
"component", "api", "profiles", len(profileIDs), "error", err)
return out
}
for profileID, effective := range resolved {
prefs := defaults
for _, eff := range effective {
applyProfilePreference(&prefs, eff.Key, eff.Value)
}
out[profileID] = prefs
}
return out
}
// contractProfilePreferences is the block every profile starts from: the
// contract's own defaults, decoded once per request.
//
// It is derived from the manifest rather than hard-coded so a default that
// changes there changes here too. A contract that fails to load leaves the Go
// zero values, which is the same "no preference" the empty string and false
// have always spelled in this DTO.
func contractProfilePreferences() profilePreferences {
var prefs profilePreferences
contract, err := settingscontract.Load()
if err != nil {
return prefs
}
for _, key := range profilePreferenceKeys {
def, ok := contract.Lookup(key)
if !ok {
continue
}
applyProfilePreference(&prefs, key, def.DefaultValue)
}
return prefs
}
// applyProfilePreference decodes one canonical value into its DTO field.
//
// A value that fails to decode leaves the field as it was, so a single
// malformed row degrades one field to its default instead of the whole block.
// The language keys default to JSON null, which unmarshals into "" — the same
// spelling of "no preference" the legacy columns used.
func applyProfilePreference(prefs *profilePreferences, key string, value json.RawMessage) {
switch key {
case settingskeys.PlaybackAudioLanguage:
decodeSettingString(value, &prefs.AudioLanguage)
case settingskeys.CatalogMetadataLanguage:
decodeSettingString(value, &prefs.MetadataLanguage)
case settingskeys.PlaybackSubtitleLanguage:
decodeSettingString(value, &prefs.SubtitleLanguage)
case settingskeys.PlaybackSubtitleMode:
decodeSettingString(value, &prefs.SubtitleMode)
case settingskeys.PlaybackShowForcedSubtitles:
var forced bool
if json.Unmarshal(value, &forced) == nil {
prefs.ShowForcedSubtitles = forced
}
}
}
func decodeSettingString(value json.RawMessage, dst *string) {
var decoded string
if json.Unmarshal(value, &decoded) == nil {
*dst = strings.TrimSpace(decoded)
}
}
@@ -1,610 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// These tests pin the seam the settings cutover opened: every server-side
// reader of the profile preferences resolves them from user_setting_values,
// while the shipped clients still write them through POST/PUT /profiles. A
// profile write that does not land in the canonical store never takes effect
// — the stale backfilled row (or the contract default) wins forever.
// updateProfileVia sends PUT /profiles/{id} as profile-1's own session.
func updateProfileVia(t *testing.T, handler *ProfileHandler, profileID, body string) *httptest.ResponseRecorder {
t.Helper()
req := newAuthorizedProfileRequestWithRole(
http.MethodPut, "/profiles/"+profileID, body, "user", profileID)
req = withProfileRouteParam(req, "id", profileID)
rr := httptest.NewRecorder()
handler.HandleUpdateProfile(rr, req)
return rr
}
func storedProfileSetting(t *testing.T, store userstore.UserStore, key, profileID string) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key,
Scope: settingscontract.ScopeProfile,
ProfileID: profileID,
})
if err != nil {
t.Fatalf("reading canonical %s: %v", key, err)
}
return value
}
// TestUpdateProfileSyncsCanonicalMetadataLanguage replays the cutover bug: a
// backfilled canonical row said "fr", the user changes the metadata language
// to "de" through the legacy profile endpoint, and access-scope resolution
// must see "de" — not the stale "fr" the one-time backfill left behind.
func TestUpdateProfileSyncsCanonicalMetadataLanguage(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
// The one-time backfill stored the pre-cutover column value.
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(`"fr"`)); err != nil {
t.Fatalf("seeding backfilled row: %v", err)
}
rr := updateProfileVia(t, handler, "profile-1", `{"preferred_metadata_language":"de"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
// The SQLite per-user schema never grew a preferred_metadata_language
// column, so the canonical row is the only storage this write has — which
// is exactly why the sync must exist.
if got := access.PreferredMetadataLanguage(context.Background(), store, "profile-1"); got != "de" {
t.Errorf("canonical metadata language = %q after profile update, want %q", got, "de")
}
}
// TestUpdateProfileSyncsCanonicalAudioLanguage is the playback-start half: a
// profile that never had a backfilled row chooses a spoken language, and the
// canonical store — which handleStartPlaybackLegacy resolves — must carry it.
func TestUpdateProfileSyncsCanonicalAudioLanguage(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1", `{"language":"de"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
value := storedProfileSetting(t, store, settingskeys.PlaybackAudioLanguage, "profile-1")
if value == nil {
t.Fatal("no canonical playback.audio_language row after the profile update")
}
if string(value.Value) != `"de"` {
t.Errorf("canonical audio language = %s, want \"de\"", value.Value)
}
}
// TestUpdateProfileClearingLanguageClearsCanonicalRow: the legacy empty
// string means "no preference", spelled canonically as no row at all.
func TestUpdateProfileClearingLanguageClearsCanonicalRow(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
if rr := updateProfileVia(t, handler, "profile-1",
`{"preferred_metadata_language":"fr"}`); rr.Code != http.StatusOK {
t.Fatalf("seeding PUT = %d: %s", rr.Code, rr.Body.String())
}
if rr := updateProfileVia(t, handler, "profile-1",
`{"preferred_metadata_language":""}`); rr.Code != http.StatusOK {
t.Fatalf("clearing PUT = %d: %s", rr.Code, rr.Body.String())
}
if value := storedProfileSetting(t, store, settingskeys.CatalogMetadataLanguage, "profile-1"); value != nil {
t.Errorf("canonical row = %s after clearing, want none", value.Value)
}
if got := access.PreferredMetadataLanguage(context.Background(), store, "profile-1"); got != "" {
t.Errorf("resolved metadata language = %q after clearing, want \"\"", got)
}
}
// TestUpdateProfileSyncsSubtitlePreferences covers the triple the player's
// subtitle picker still saves through PUT /profiles, resolved canonically by
// catalog detail since the earlier cutover.
func TestUpdateProfileSyncsSubtitlePreferences(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1",
`{"subtitle_language":"ja","subtitle_mode":"always","show_forced_subtitles":false}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
for key, want := range map[string]string{
settingskeys.PlaybackSubtitleLanguage: `"ja"`,
settingskeys.PlaybackSubtitleMode: `"always"`,
settingskeys.PlaybackShowForcedSubtitles: `false`,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
t.Errorf("no canonical %s row after the profile update", key)
continue
}
if string(value.Value) != want {
t.Errorf("canonical %s = %s, want %s", key, value.Value, want)
}
}
}
// TestUpdateProfileSyncsSkipPreferences. The player resolves these four keys
// canonically, so a legacy PUT that only moved the columns would return 200
// and change nothing about playback.
func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1",
`{"auto_skip_intro":true,"auto_skip_credits":true,"auto_skip_recap":true,`+
`"auto_play_next_preview":false}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
for key, want := range map[string]string{
settingskeys.PlaybackAutoSkipIntro: `true`,
settingskeys.PlaybackAutoSkipCredits: `true`,
settingskeys.PlaybackAutoSkipRecap: `true`,
settingskeys.PlaybackAutoPlayNextPreview: `false`,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
t.Errorf("no canonical %s row after the profile update", key)
continue
}
if string(value.Value) != want {
t.Errorf("canonical %s = %s, want %s", key, value.Value, want)
}
}
// A field the request omitted must not be written: the shipped clients
// send single-field deltas, and an absent field is not a choice. Its own
// store, since the test store's DSN is derived from the test name.
t.Run("omitted fields are not written", func(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
if rr := updateProfileVia(t, handler, "profile-1", `{"auto_skip_intro":true}`); rr.Code != http.StatusOK {
t.Fatalf("single-field PUT = %d: %s", rr.Code, rr.Body.String())
}
if value := storedProfileSetting(t, store, settingskeys.PlaybackAutoSkipCredits, "profile-1"); value != nil {
t.Errorf("an omitted field wrote %s", value.Value)
}
})
}
// TestUpdateProfileRejectsInvalidLanguageBeforeWriting: a value the canonical
// endpoint would refuse must fail the request as a no-op instead of leaving
// the column and the canonical store disagreeing.
func TestUpdateProfileRejectsInvalidLanguageBeforeWriting(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1", `{"language":"!!!"}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("PUT of an invalid tag = %d, want 400: %s", rr.Code, rr.Body.String())
}
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.Language != "" {
t.Errorf("column = %q after a rejected write, want untouched", profile.Language)
}
if value := storedProfileSetting(t, store, settingskeys.PlaybackAudioLanguage, "profile-1"); value != nil {
t.Errorf("canonical row = %s after a rejected write, want none", value.Value)
}
}
// TestCreateProfileSyncsCanonicalLanguages: a profile born with preferences
// must be resolvable canonically from its first request.
func TestCreateProfileSyncsCanonicalLanguages(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
req := newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles",
`{"name":"Kids","language":"de","preferred_metadata_language":"fr"}`,
"user", "profile-1")
rr := httptest.NewRecorder()
handler.HandleCreateProfile(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rr.Code, rr.Body.String())
}
var created profileResponse
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding create response: %v", err)
}
audio := storedProfileSetting(t, store, settingskeys.PlaybackAudioLanguage, created.ID)
if audio == nil || string(audio.Value) != `"de"` {
t.Errorf("canonical audio language after create = %v, want \"de\"", audio)
}
if got := access.PreferredMetadataLanguage(context.Background(), store, created.ID); got != "fr" {
t.Errorf("canonical metadata language after create = %q, want %q", got, "fr")
}
}
func TestCreateProfileInheritsSurvivingLegacyAccountSettings(t *testing.T) {
store := newProfileTestStore(t)
if err := store.SetSetting(context.Background(), searchMediaScopeSettingKey, "audiobook"); err != nil {
t.Fatalf("seeding legacy account setting: %v", err)
}
handler := NewProfileHandler(testUserStoreProvider{store: store})
req := newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles",
`{"name":"Guest"}`, "user", "profile-1")
rec := httptest.NewRecorder()
handler.HandleCreateProfile(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body.String())
}
var created profileResponse
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding create response: %v", err)
}
value := storedProfileSetting(t, store, searchMediaScopeSettingKey, created.ID)
if value == nil || string(value.Value) != `"audiobook"` {
t.Fatalf("inherited canonical value = %+v", value)
}
}
// failingSettingsWriteStore fails every canonical setting write, simulating a
// store whose user_setting_values table is unavailable while profile CRUD
// still works.
type failingSettingsWriteStore struct {
userstore.UserStore
}
type failingPreferenceSettingsWriter struct {
userstore.PreferenceSettingsWriter
}
func (s failingSettingsWriteStore) UpsertSettingValue(
context.Context, userstore.SettingIdentity, json.RawMessage,
) (*userstore.SettingValue, error) {
return nil, errors.New("settings storage unavailable")
}
func (s failingSettingsWriteStore) WithPreferenceSettingsTransaction(
ctx context.Context,
fn func(userstore.PreferenceSettingsWriter) error,
) error {
transactioner, ok := s.UserStore.(userstore.PreferenceSettingsTransactioner)
if !ok {
return errors.New("wrapped store does not support preference settings transactions")
}
return transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
return fn(failingPreferenceSettingsWriter{PreferenceSettingsWriter: tx})
})
}
func (w failingPreferenceSettingsWriter) UpsertSettingValue(
context.Context, userstore.SettingIdentity, json.RawMessage,
) (*userstore.SettingValue, error) {
return nil, errors.New("settings storage unavailable")
}
// TestCreateProfileRollsBackWhenSettingsSyncFails pins the atomic profile and
// canonical-settings transaction. A failed canonical write must leave no
// half-configured profile and the client's retry must not hit a name conflict.
func TestCreateProfileRollsBackWhenSettingsSyncFails(t *testing.T) {
base := newProfileTestStore(t)
store := failingSettingsWriteStore{UserStore: base}
handler := NewProfileHandler(testUserStoreProvider{store: store})
req := newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles",
`{"name":"Kids","language":"de"}`, "user", "profile-1")
rr := httptest.NewRecorder()
handler.HandleCreateProfile(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("POST = %d, want 500: %s", rr.Code, rr.Body.String())
}
profiles, err := base.ListProfiles(context.Background())
if err != nil {
t.Fatalf("listing profiles: %v", err)
}
for _, p := range profiles {
if p.Name == "Kids" {
t.Fatalf("profile %q survived a failed settings sync", p.Name)
}
}
// The rollback lets the retry succeed once the store recovers.
retry := newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles",
`{"name":"Kids","language":"de"}`, "user", "profile-1")
retryRec := httptest.NewRecorder()
NewProfileHandler(testUserStoreProvider{store: base}).HandleCreateProfile(retryRec, retry)
if retryRec.Code != http.StatusCreated {
t.Fatalf("retry POST = %d, want 201: %s", retryRec.Code, retryRec.Body.String())
}
}
func TestUpdateProfileRollsBackWhenSettingsSyncFails(t *testing.T) {
base := newProfileTestStore(t)
store := failingSettingsWriteStore{UserStore: base}
handler := NewProfileHandler(testUserStoreProvider{store: store})
before, err := base.GetProfile(context.Background(), "profile-1")
if err != nil || before == nil {
t.Fatalf("reading profile before update: profile=%+v err=%v", before, err)
}
rr := updateProfileVia(t, handler, "profile-1", `{"language":"de"}`)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("PUT = %d, want 500: %s", rr.Code, rr.Body.String())
}
after, err := base.GetProfile(context.Background(), "profile-1")
if err != nil || after == nil {
t.Fatalf("reading profile after rollback: profile=%+v err=%v", after, err)
}
if after.Language != before.Language {
t.Fatalf("legacy language after rollback = %q, want %q", after.Language, before.Language)
}
if value := storedProfileSetting(t, base, settingskeys.PlaybackAudioLanguage, "profile-1"); value != nil {
t.Fatalf("canonical language survived rollback: %+v", value)
}
}
// TestUpdateProfilePublishesUserSettingsEvents: the synced rows change what
// other clients resolve, so they get the same refresh signal a
// /settings/values write publishes.
func TestUpdateProfilePublishesUserSettingsEvents(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{})
events, unsubscribe := handler.EventsHub.Subscribe()
defer unsubscribe()
rr := updateProfileVia(t, handler, "profile-1", `{"preferred_metadata_language":"de"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
env := receiveUserSettingsEvent(t, events)
assertUserSettingsEnvelope(t, env, settingskeys.CatalogMetadataLanguage, "profile")
// A field the request did not carry publishes nothing.
select {
case extra := <-events:
t.Errorf("unexpected extra event for %s", extra.Data)
default:
}
}
// --- Read side ---
//
// The mirror of the tests above: the DTO's preference fields are served from
// the canonical rows, so a write that never touched a legacy column is still
// visible to every profile-DTO reader on every platform.
// listProfilesVia sends GET /profiles as profile-1's own session.
func listProfilesVia(t *testing.T, handler *ProfileHandler) profileListResponse {
t.Helper()
req := newAuthorizedProfileRequestWithRole(http.MethodGet, "/profiles", "", "user", "profile-1")
rr := httptest.NewRecorder()
handler.HandleListProfiles(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /profiles = %d: %s", rr.Code, rr.Body.String())
}
var resp profileListResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding profile list: %v", err)
}
return resp
}
func profileFromList(t *testing.T, resp profileListResponse, profileID string) profileResponse {
t.Helper()
for _, p := range resp.Profiles {
if p.ID == profileID {
return p
}
}
t.Fatalf("profile %s missing from the list response", profileID)
return profileResponse{}
}
// TestListProfilesServesCanonicalWrite is the cross-client coherence gap this
// read path exists to close: a preference saved through PUT
// /settings/values?scope=profile writes only user_setting_values, and the
// profile DTO — which the Apple clients read — must reflect it on the next GET
// without the legacy column having moved at all.
func TestListProfilesServesCanonicalWrite(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
before, err := store.GetProfile(ctx, "profile-1")
if err != nil || before == nil {
t.Fatalf("reading the profile before the canonical write: %v", err)
}
for key, value := range map[string]string{
settingskeys.PlaybackAudioLanguage: `"de"`,
settingskeys.CatalogMetadataLanguage: `"fr"`,
settingskeys.PlaybackSubtitleLanguage: `"ja"`,
settingskeys.PlaybackSubtitleMode: `"always"`,
settingskeys.PlaybackShowForcedSubtitles: `false`,
} {
if _, err := store.UpsertSettingValue(ctx, userstore.SettingIdentity{
Key: key,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(value)); err != nil {
t.Fatalf("canonical write of %s: %v", key, err)
}
}
got := profileFromList(t, listProfilesVia(t, handler), "profile-1")
if got.Language != "de" {
t.Errorf("language = %q, want %q", got.Language, "de")
}
if got.PreferredMetadataLanguage != "fr" {
t.Errorf("preferred_metadata_language = %q, want %q", got.PreferredMetadataLanguage, "fr")
}
if got.SubtitleLanguage != "ja" {
t.Errorf("subtitle_language = %q, want %q", got.SubtitleLanguage, "ja")
}
if got.SubtitleMode != "always" {
t.Errorf("subtitle_mode = %q, want %q", got.SubtitleMode, "always")
}
if got.ShowForcedSubtitles {
t.Error("show_forced_subtitles = true, want false")
}
// The legacy columns never moved: the canonical write is the only storage
// involved, which is precisely why reading the columns hid it.
after, err := store.GetProfile(ctx, "profile-1")
if err != nil || after == nil {
t.Fatalf("reading the profile after the canonical write: %v", err)
}
if !reflect.DeepEqual(before, after) {
t.Errorf("a canonical write moved the legacy columns:\n before = %+v\n after = %+v", before, after)
}
}
// TestListProfilesFallsBackToContractDefaults: a profile with neither a
// canonical row nor column data serves the contract's defaults, not the
// columns' schema defaults. subtitle_mode is the one that shows the
// difference is real — the column defaults to 'auto' and so does the
// contract, so show_forced_subtitles and the languages carry the assertion.
func TestListProfilesFallsBackToContractDefaults(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
got := profileFromList(t, listProfilesVia(t, handler), "profile-1")
if got.Language != "" {
t.Errorf("language = %q, want the contract default \"\"", got.Language)
}
if got.PreferredMetadataLanguage != "" {
t.Errorf("preferred_metadata_language = %q, want the contract default \"\"",
got.PreferredMetadataLanguage)
}
if got.SubtitleLanguage != "" {
t.Errorf("subtitle_language = %q, want the contract default \"\"", got.SubtitleLanguage)
}
if got.SubtitleMode != "auto" {
t.Errorf("subtitle_mode = %q, want the contract default %q", got.SubtitleMode, "auto")
}
if !got.ShowForcedSubtitles {
t.Error("show_forced_subtitles = false, want the contract default true")
}
}
// TestListProfilesRoundTripsLegacyWrite: the legacy write path still works
// end to end. The columns are no longer read, so this only passes because the
// write mirrors into the canonical rows — which is the whole cutover shape,
// and the regression that would break every shipped client if the sync broke.
func TestListProfilesRoundTripsLegacyWrite(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1",
`{"language":"es","preferred_metadata_language":"it","subtitle_language":"ko",`+
`"subtitle_mode":"off","show_forced_subtitles":false}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
// The update response and the next list must agree; both serve resolution.
var updated profileResponse
if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil {
t.Fatalf("decoding update response: %v", err)
}
listed := profileFromList(t, listProfilesVia(t, handler), "profile-1")
if !reflect.DeepEqual(updated, listed) {
t.Errorf("update response and list disagree:\n update = %+v\n list = %+v", updated, listed)
}
if listed.Language != "es" {
t.Errorf("language = %q, want %q", listed.Language, "es")
}
if listed.PreferredMetadataLanguage != "it" {
t.Errorf("preferred_metadata_language = %q, want %q", listed.PreferredMetadataLanguage, "it")
}
if listed.SubtitleLanguage != "ko" {
t.Errorf("subtitle_language = %q, want %q", listed.SubtitleLanguage, "ko")
}
if listed.SubtitleMode != "off" {
t.Errorf("subtitle_mode = %q, want %q", listed.SubtitleMode, "off")
}
if listed.ShowForcedSubtitles {
t.Error("show_forced_subtitles = true, want false")
}
}
// TestListProfilesResolvesHouseholdInOneRead: the list serves several
// profiles, so it must not cost a store read each. It also pins that one
// profile's preference never leaks into another's.
func TestListProfilesResolvesHouseholdInOneRead(t *testing.T) {
ctx := context.Background()
base := newProfileTestStore(t)
if err := base.CreateProfile(ctx, userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
t.Fatalf("creating the second profile: %v", err)
}
for profileID, language := range map[string]string{
"profile-1": `"de"`,
"profile-2": `"ja"`,
} {
if _, err := base.UpsertSettingValue(ctx, userstore.SettingIdentity{
Key: settingskeys.PlaybackSubtitleLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: profileID,
}, json.RawMessage(language)); err != nil {
t.Fatalf("canonical write for %s: %v", profileID, err)
}
}
store := &countingResolutionStore{UserStore: base}
handler := NewProfileHandler(testUserStoreProvider{store: store})
resp := listProfilesVia(t, handler)
if got := profileFromList(t, resp, "profile-1").SubtitleLanguage; got != "de" {
t.Errorf("profile-1 subtitle_language = %q, want %q", got, "de")
}
if got := profileFromList(t, resp, "profile-2").SubtitleLanguage; got != "ja" {
t.Errorf("profile-2 subtitle_language = %q, want %q", got, "ja")
}
if store.reads != 1 {
t.Errorf("listing %d profiles issued %d resolution reads, want 1",
len(resp.Profiles), store.reads)
}
}
// countingResolutionStore counts the batched resolution reads a request makes,
// so a regression to one read per profile fails rather than merely slowing
// the list down.
type countingResolutionStore struct {
userstore.UserStore
reads int
}
func (s *countingResolutionStore) ListSettingValuesForResolution(
ctx context.Context, query userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error) {
s.reads++
return s.UserStore.ListSettingValuesForResolution(ctx, query)
}
+3 -2
View File
@@ -1499,7 +1499,7 @@ func (h *SectionHandler) sectionPresignURL(r *http.Request, path string, variant
}
// maybeInjectNextUp injects a SectionNextUp entry after SectionContinueWatching
// if the profile's ui.next_up_mode setting resolves to "separate".
// if the user's next_up_mode setting is "separate".
func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []sections.ResolvedSection, userID int) []sections.ResolvedSection {
if h.StoreProvider == nil || userID <= 0 {
return resolved
@@ -1508,7 +1508,8 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti
if err != nil {
return resolved
}
if sections.NextUpMode(ctx, store, apimw.GetProfileID(ctx)) == sections.NextUpModeSeparate {
mode, _ := store.GetSetting(ctx, "next_up_mode")
if mode == "separate" {
return injectNextUpSection(resolved)
}
return resolved
+28 -241
View File
@@ -15,9 +15,6 @@ import (
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingsmigrate"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -58,7 +55,6 @@ type SettingsHandler struct {
storeProvider userstore.UserStoreProvider
serverSettings ServerSettingReader
deviceSeen *cache.TTLCache[struct{}]
EventsHub *evt.Hub
}
// NewSettingsHandler creates a new SettingsHandler.
@@ -161,7 +157,12 @@ var settingsRegistry = map[string]settingSpec{
"playback.audio_language": {
Scope: scopeDevice,
DefaultValue: "",
Validate: validateLanguageTagSetting("playback.audio_language"),
Validate: func(value string) error {
if len(strings.TrimSpace(value)) > 32 {
return fmt.Errorf("playback.audio_language must be 32 characters or fewer")
}
return nil
},
},
"playback.auto_skip_intro": {
Scope: scopeDevice,
@@ -252,12 +253,7 @@ var settingsRegistry = map[string]settingSpec{
"player.playback_speed": {
Scope: scopeDevice,
DefaultValue: "1",
// Range only, no step: this endpoint accepted any in-range speed
// before the contract landed, and v1 rules forbid turning an existing
// 204 into a 400 before the coordinated cutover. The typed mutation
// endpoint enforces the manifest's 0.05 step, and the migration snaps
// historical off-step values onto the grid rather than dropping them.
Validate: validateFloatRange("player.playback_speed", 0.25, 3.0),
Validate: validateFloatRange("player.playback_speed", 0.25, 3.0),
},
"player.audio_sync_ms": {
Scope: scopeDevice,
@@ -382,7 +378,7 @@ func (h *SettingsHandler) HandleSetSetting(w http.ResponseWriter, r *http.Reques
return
}
if err := h.syncLegacyUserSetting(r.Context(), store, userID, key, &req.Value); err != nil {
if err := store.SetSetting(r.Context(), key, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set setting")
return
}
@@ -410,7 +406,7 @@ func (h *SettingsHandler) HandleDeleteSetting(w http.ResponseWriter, r *http.Req
return
}
if err := h.syncLegacyUserSetting(r.Context(), store, userID, key, nil); err != nil {
if err := store.DeleteSetting(r.Context(), key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete setting")
return
}
@@ -506,18 +502,26 @@ func (h *SettingsHandler) HandleSetDeviceSetting(w http.ResponseWriter, r *http.
return
}
entry := userstore.DeviceSettingEntry{
if err := store.SetDeviceSetting(r.Context(), userstore.DeviceSettingEntry{
ProfileID: profileID,
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
DevicePlatform: device.DevicePlatform,
Key: key,
Value: req.Value,
}
if err := h.syncLegacyDeviceSetting(r.Context(), store, userID, entry, &req.Value); err != nil {
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set device setting")
return
}
if legacyKey, ok := legacyDeviceSettingKey(key); ok {
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, legacyKey); err != nil {
slog.WarnContext(r.Context(), "failed to clean up legacy device setting after canonical write",
"legacy_key", legacyKey,
"canonical_key", key,
"error", err,
)
}
}
w.WriteHeader(http.StatusNoContent)
}
@@ -553,8 +557,13 @@ func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *ht
}
h.registerRequestDevice(r.Context(), store, profileID, device)
entry := userstore.DeviceSettingEntry{ProfileID: profileID, DeviceID: device.DeviceID, Key: key}
if err := h.syncLegacyDeviceSetting(r.Context(), store, userID, entry, nil); err != nil {
if legacyKey, ok := legacyDeviceSettingKey(key); ok {
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, legacyKey); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
return
}
}
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
return
}
@@ -562,174 +571,6 @@ func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *ht
w.WriteHeader(http.StatusNoContent)
}
func planLegacyRuntimeSettings(key string, value *string) ([]profileSettingSync, error) {
contract, err := settingscontract.Load()
if err != nil {
return nil, fmt.Errorf("loading settings contract: %w", err)
}
planner := settingsmigrate.New(contract, settingscontract.ObjectSchemas())
if value == nil {
keys := planner.RuntimeKeys(key)
if len(keys) == 0 {
return nil, fmt.Errorf("%s has no canonical runtime target", key)
}
out := make([]profileSettingSync, 0, len(keys))
for _, canonicalKey := range keys {
out = append(out, profileSettingSync{key: canonicalKey})
}
return out, nil
}
planned, err := planner.PlanRuntimeValue(key, *value)
if err != nil {
return nil, err
}
out := make([]profileSettingSync, 0, len(planned))
for _, mutation := range planned {
out = append(out, profileSettingSync{key: mutation.Key, value: mutation.Value})
}
return out, nil
}
// syncLegacyUserSetting commits the account-wide legacy row and its
// profile-scoped canonical fan-out together. The old endpoint was shared by
// every household profile, so mirroring only the active profile would change
// its shipped semantics.
func (h *SettingsHandler) syncLegacyUserSetting(
ctx context.Context,
store userstore.UserStore,
userID int,
key string,
value *string,
) error {
writes, err := planLegacyRuntimeSettings(key, value)
if err != nil {
// The surviving v1 route historically accepted its registry validation.
// Some JSON entries are intentionally looser than the new typed schema;
// preserve their successful legacy write instead of changing 204 to 500.
slog.WarnContext(ctx, "legacy user setting has no canonical representation; preserving legacy write",
"component", "api", "key", key, "error", err)
writes = nil
}
transactioner, ok := store.(userstore.PreferenceSettingsTransactioner)
if !ok {
return fmt.Errorf("user store does not support atomic preference settings synchronization")
}
type changedProfile struct {
profileID string
keys []string
}
var changed []changedProfile
err = transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
if value == nil {
if err := tx.DeleteSetting(ctx, key); err != nil {
return err
}
} else if err := tx.SetSetting(ctx, key, *value); err != nil {
return err
}
profileIDs, err := tx.ListProfileIDs(ctx)
if err != nil {
return fmt.Errorf("listing profiles for settings synchronization: %w", err)
}
changed = make([]changedProfile, 0, len(profileIDs))
for _, profileID := range profileIDs {
keys, err := writeCanonicalSettingsSync(ctx, tx, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfile, ProfileID: profileID,
}, writes)
if err != nil {
return err
}
changed = append(changed, changedProfile{profileID: profileID, keys: keys})
}
return nil
})
if err != nil {
return err
}
for _, profile := range changed {
for _, changedKey := range profile.keys {
publishUserSettingsEvent(ctx, h.EventsHub, userID, profile.profileID,
changedKey, string(settingscontract.ScopeProfile))
}
}
return nil
}
// syncLegacyDeviceSetting mirrors a shipped device-setting mutation to its
// profile_device canonical rows. Alias cleanup participates in the same
// transaction, so a failure cannot leave the two spellings disagreeing.
func (h *SettingsHandler) syncLegacyDeviceSetting(
ctx context.Context,
store userstore.UserStore,
userID int,
entry userstore.DeviceSettingEntry,
value *string,
) error {
writes, err := planLegacyRuntimeSettings(entry.Key, value)
if err != nil {
// Keep the established loose JSON endpoint compatible when a syntactically
// valid legacy document cannot satisfy the stricter canonical schema.
slog.WarnContext(ctx, "legacy device setting has no canonical representation; preserving legacy write",
"component", "api", "key", entry.Key, "error", err)
writes = nil
}
base := userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
ProfileID: entry.ProfileID, DeviceID: entry.DeviceID,
}
return applyLegacyPreferenceSettingsSync(ctx, store, h.EventsHub, userID, base, writes,
func(tx userstore.PreferenceSettingsWriter) error {
if value == nil {
if legacyKey, ok := legacyDeviceSettingKey(entry.Key); ok {
if err := tx.DeleteDeviceSetting(ctx, entry.ProfileID, entry.DeviceID, legacyKey); err != nil {
return err
}
}
return tx.DeleteDeviceSetting(ctx, entry.ProfileID, entry.DeviceID, entry.Key)
}
entry.Value = *value
if err := tx.SetDeviceSetting(ctx, entry); err != nil {
return err
}
if legacyKey, ok := legacyDeviceSettingKey(entry.Key); ok {
return tx.DeleteDeviceSetting(ctx, entry.ProfileID, entry.DeviceID, legacyKey)
}
return nil
})
}
// planInheritedLegacyUserSettings captures the account-wide settings a newly
// created profile must inherit while the old generic routes remain mounted.
// Profile creation calls this inside the same preference transaction as the
// insert; PostgreSQL's per-user advisory lock and SQLite's write transaction
// serialize it with the account-setting fan-out path.
func planInheritedLegacyUserSettings(
ctx context.Context,
store interface {
ListSettings(context.Context) ([]userstore.SettingEntry, error)
},
) ([]profileSettingSync, error) {
entries, err := store.ListSettings(ctx)
if err != nil {
return nil, fmt.Errorf("listing legacy user settings: %w", err)
}
var out []profileSettingSync
for _, entry := range entries {
if !keyUsesUserScope(entry.Key) {
continue
}
value := entry.Value
planned, err := planLegacyRuntimeSettings(entry.Key, &value)
if err != nil {
slog.WarnContext(ctx, "legacy user setting cannot seed a new canonical profile",
"component", "api", "key", entry.Key, "error", err)
continue
}
out = append(out, planned...)
}
return out, nil
}
// HandleGetEffectiveSettings handles GET /settings/effective?keys=key1,key2
func (h *SettingsHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
@@ -917,25 +758,10 @@ func validateRegisteredSetting(key, value string, expectedScope settingsScope) e
return spec.Validate(value)
}
// keyUsesUserScope reports whether a key is stored at account scope by the
// legacy endpoints.
//
// This used to return true for any *unregistered* key, which is the extension
// bag: a client could invent a production setting unilaterally and the server
// stored it as an unvalidated string. That is how six ui.* settings and five
// orphan keys reached production untyped, and closing it is the point of the
// contract.
//
// An unknown key is now simply not a user setting, so the legacy write path
// rejects it and the canonical API — which validates against the manifest — is
// the only way to store anything new. That includes the jellycompat:* keys the
// Jellyfin DisplayPreferences blobs once rode this table under: they live in
// the dedicated jellycompat_displayprefs table now, and this API neither
// accepts nor surfaces them.
func keyUsesUserScope(key string) bool {
key = canonicalDeviceSettingKey(key)
spec, ok := settingsRegistry[key]
return ok && spec.Scope == scopeUser
return !ok || spec.Scope == scopeUser
}
func keyUsesDeviceScope(key string) bool {
@@ -1036,18 +862,6 @@ func validateIntRange(key string, min, max int) func(string) error {
}
func validateFloatRange(key string, min, max float64) func(string) error {
return validateFloatRangeStep(key, min, max, 0)
}
// validateFloatRangeStep enforces the range and, when step is positive, that
// the value sits on the step grid anchored at min.
//
// The step check delegates to settingscontract.StepAligned so this endpoint
// enforces exactly what contracts/settings/v1/manifest.json declares. Before
// this, player.playback_speed advertised a 0.05 step that nothing enforced, so
// the server happily stored 0.26 — a value no client's stepper can represent
// and that every client would silently snap on the next write.
func validateFloatRangeStep(key string, min, max, step float64) func(string) error {
return func(value string) error {
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
@@ -1056,33 +870,6 @@ func validateFloatRangeStep(key string, min, max, step float64) func(string) err
if math.IsNaN(parsed) || parsed < min || parsed > max {
return fmt.Errorf("%s must be between %g and %g", key, min, max)
}
if !settingscontract.StepAligned(parsed, min, step) {
return fmt.Errorf("%s must be a multiple of %g starting from %g", key, step, min)
}
return nil
}
}
// validateLanguageTagSetting accepts a BCP 47 language tag, or the empty string.
//
// The empty string is the legacy wire form for "no preference": the string-only
// settings API has no way to send null, and both the Android and web clients
// send "" to clear the choice. The contract expresses the same state as null,
// which is why every language definition there is nullable.
//
// Anything else must be a well-formed tag. The previous check was "32
// characters or fewer", so the server accepted "!!!" for a field the manifest
// declares as language_tag — and stored it where track matching would silently
// never match.
func validateLanguageTagSetting(key string) func(string) error {
return func(value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
if _, ok := settingscontract.NormalizeLanguageTag(trimmed); !ok {
return fmt.Errorf("%s must be a BCP 47 language tag such as en or en-US", key)
}
return nil
}
}
-80
View File
@@ -1,80 +0,0 @@
package handlers
import (
"context"
"log/slog"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
)
// Audit records for settings changes made *for someone else*.
//
// Ordinary self-service writes are deliberately not audited: they are the
// common case by orders of magnitude, and a trail that records everything
// answers nothing. What needs an answer is "who turned subtitles on for
// Robin?" — a household parent acting for another profile, or an admin acting
// on an account.
//
// The record carries identity only, never the value. The realtime event makes
// the same choice for the same reason (see user_settings_events.go): a value
// here would put one profile's private settings into a log an operator reads.
//
// This is a structured log record rather than a row in activity_log:
// activitylog is an HTTP request-log middleware whose schema has no profile or
// body, so it cannot express "actor P changed key K for profile Q". Persisting
// these needs its own table and migration.
const settingsAuditMsg = "settings changed for another profile"
// logComponentKey is the structured-log attribute every handler in this package
// tags itself with.
const logComponentKey = "component"
type settingsAuditRecord struct {
Action string
ActorProfileID string
TargetProfileID string
// TargetUserID is the account the change lands on. It differs from the
// actor's own account only on the admin routes, where profile ids alone
// would not say whose settings moved.
TargetUserID int
DeviceID string
Key string
Scope string
}
// auditSettingsForOther emits the record when, and only when, the actor is
// acting for someone else — another profile, or (on the admin routes) another
// account entirely.
func auditSettingsForOther(ctx context.Context, record settingsAuditRecord) {
actorUserID := apimw.GetUserID(ctx)
sameProfile := record.TargetProfileID == "" || record.TargetProfileID == record.ActorProfileID
sameUser := record.TargetUserID == 0 || record.TargetUserID == actorUserID
if sameProfile && sameUser {
return
}
attrs := []any{
logComponentKey, "api",
"action", record.Action,
"actor_user_id", actorUserID,
"actor_profile_id", record.ActorProfileID,
"target_user_id", record.TargetUserID,
"target_profile_id", record.TargetProfileID,
"acting_as_admin", apimw.IsAdmin(ctx),
}
if record.Key != "" {
attrs = append(attrs, "setting_key", record.Key)
}
if record.Scope != "" {
attrs = append(attrs, "scope", record.Scope)
}
if record.DeviceID != "" {
attrs = append(attrs, "device_id", record.DeviceID)
}
slog.InfoContext(ctx, settingsAuditMsg, attrs...)
}
// actingProfileID is the profile the caller is signed in as, which is not
// necessarily the profile a request addresses.
func actingProfileID(ctx context.Context) string {
return apimw.GetProfileID(ctx)
}
@@ -1,236 +0,0 @@
package handlers
import (
"encoding/json"
"strconv"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
)
// contractKeyRenames maps a legacy registry key to the canonical contract key
// where the two deliberately differ.
//
// This is the only handwritten part of the cross-check, and it encodes a
// decision rather than an inventory: every entry is a rename the manifest notes
// justify. The registry itself is iterated, never transcribed — a hand-copied
// key list cannot detect a key added to one side and not the other, which is
// the drift this whole contract exists to prevent.
var contractKeyRenames = map[string]string{
"subtitle_appearance": "playback.subtitle_appearance",
}
func canonicalContractKey(registryKey string) string {
if canonical, ok := contractKeyRenames[registryKey]; ok {
return canonical
}
return registryKey
}
// TestEverySettingsRegistryKeyIsRegisteredInTheContract is the gate that makes
// the manifest authoritative rather than descriptive. Adding a key to
// settingsRegistry without a manifest definition fails here.
func TestEverySettingsRegistryKeyIsRegisteredInTheContract(t *testing.T) {
manifest, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading settings contract: %v", err)
}
for registryKey := range settingsRegistry {
canonical := canonicalContractKey(registryKey)
if _, ok := manifest.Lookup(canonical); !ok {
t.Errorf("settingsRegistry key %q has no definition in "+
"contracts/settings/v1/manifest.json (looked up %q). Add one, or add a "+
"rename to contractKeyRenames if the canonical name differs.",
registryKey, canonical)
}
}
}
// TestContractRenamesStayLive keeps the rename table honest: an entry for a key
// the registry no longer has is dead weight that hides the next real rename.
func TestContractRenamesStayLive(t *testing.T) {
for registryKey := range contractKeyRenames {
if _, ok := settingsRegistry[registryKey]; !ok {
t.Errorf("contractKeyRenames maps %q, which settingsRegistry no longer defines",
registryKey)
}
}
}
// TestRegistryDefaultsMatchTheContract catches the failure mode that is silent
// in production: the two sides agree a setting exists and disagree on what it
// resolves to when nobody has set it. A user who never touched the toggle gets
// one answer from the server today and a different one from a manifest-driven
// client tomorrow.
func TestRegistryDefaultsMatchTheContract(t *testing.T) {
manifest, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading settings contract: %v", err)
}
for registryKey, spec := range settingsRegistry {
canonical := canonicalContractKey(registryKey)
def, ok := manifest.Lookup(canonical)
if !ok {
continue // reported by the coverage test above
}
t.Run(registryKey, func(t *testing.T) {
// The null case is settled before scalarDefault, which rejects null
// as non-scalar. Asking it first skipped the subtest and left the
// comparison below unreachable, so a nullable contract default could
// drift from the registry without failing anything.
//
// The legacy registry stores every value as a string and has no way
// to say "unset", so it spells that as the empty string. The
// contract spells it null, which is why the language settings are
// nullable. Those are the same statement, not a disagreement — but
// null against a non-empty registry default is a real one.
if strings.TrimSpace(string(def.DefaultValue)) == "null" {
if spec.DefaultValue != "" {
t.Errorf("default disagrees: settingsRegistry has %q, contract has null",
spec.DefaultValue)
}
return
}
contractDefault, err := scalarDefault(def.DefaultValue)
if err != nil {
t.Skipf("contract default is not a scalar: %s", def.DefaultValue)
}
if spec.DefaultValue != contractDefault {
t.Errorf("default disagrees: settingsRegistry has %q, contract has %q",
spec.DefaultValue, contractDefault)
}
})
}
}
// scalarDefault renders a contract default the way the legacy registry would
// have stored it, so the two can be compared.
func scalarDefault(raw json.RawMessage) (string, error) {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return "", err
}
switch typed := value.(type) {
case string:
return typed, nil
case bool:
return strconv.FormatBool(typed), nil
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64), nil
default:
return "", errNotScalar
}
}
var errNotScalar = &notScalarError{}
type notScalarError struct{}
func (*notScalarError) Error() string { return "not a scalar default" }
// TestContractLoadsUnderTheServerBuild is a cheap canary: the handlers package
// is linked into cmd/silo, so if the embedded manifest is self-inconsistent the
// failure shows up here rather than at a customer's startup.
func TestContractLoadsUnderTheServerBuild(t *testing.T) {
manifest, err := settingscontract.Load()
if err != nil {
t.Fatalf("embedded settings contract is invalid: %v", err)
}
if len(manifest.Keys()) == 0 {
t.Fatal("settings contract declares no keys")
}
for _, key := range manifest.Keys() {
if strings.TrimSpace(key) == "" {
t.Error("contract declares an empty key")
}
}
}
// TestAudioLanguageRejectsMalformedTags closes a drift measured against the
// live server: the manifest declares playback.audio_language as language_tag,
// but the registry check was "32 characters or fewer", so "!!!" was stored for
// a field track matching would then silently never match.
func TestAudioLanguageRejectsMalformedTags(t *testing.T) {
const key = "playback.audio_language"
// The empty string is how the string-only API says "no preference", and
// both Android and web send it to clear the choice. It must keep working.
accepted := []string{"", " ", "en", "EN", "en-US", "en_US", "pt-BR", "zh-Hant-TW", "es-419"}
for _, v := range accepted {
if err := validateRegisteredSetting(key, v, scopeDevice); err != nil {
t.Errorf("value %q was rejected: %v", v, err)
}
}
rejected := []string{"!!!", "english please", "e", "en-", "-US", "en--US", "123", "<script>"}
for _, v := range rejected {
if err := validateRegisteredSetting(key, v, scopeDevice); err == nil {
t.Errorf("value %q was accepted; the manifest declares this key as language_tag", v)
}
}
}
// TestPlaybackSpeedEnforcesDeclaredStep closes the other measured drift: the
// manifest declares step 0.05 over 0.25..3.0 and nothing enforced it, so the
// server stored values no client's stepper can represent.
func TestPlaybackSpeedEnforcesDeclaredStep(t *testing.T) {
const key = "player.playback_speed"
for _, v := range []string{"0.25", "0.75", "1", "1.0", "1.25", "1.4", "2.5", "3", "3.0"} {
if err := validateRegisteredSetting(key, v, scopeDevice); err != nil {
t.Errorf("on-step value %q was rejected: %v", v, err)
}
}
// Off-step but in-range values stay accepted on this legacy endpoint:
// it accepted them before the contract landed, and v1 rules forbid
// turning that 204 into a 400 before the coordinated cutover. Step
// enforcement lives on the typed mutation endpoint, and the migration
// snaps historical off-step values onto the grid.
for _, v := range []string{"0.26", "1.4372", "1.01", "2.99"} {
if err := validateRegisteredSetting(key, v, scopeDevice); err != nil {
t.Errorf("in-range value %q was rejected on the legacy endpoint: %v", v, err)
}
}
// Range still enforced, as it always was.
for _, v := range []string{"0.2", "3.05", "abc"} {
if err := validateRegisteredSetting(key, v, scopeDevice); err == nil {
t.Errorf("out-of-range value %q was accepted", v)
}
}
}
// TestRegistryStepMatchesTheManifest keeps the two in lockstep: if the manifest
// widens or narrows the step, this fails until the typed endpoint follows.
// The legacy registry deliberately does not enforce the step — see the
// player.playback_speed entry — so the check runs against the contract
// validator the typed mutation endpoint uses.
func TestRegistryStepMatchesTheManifest(t *testing.T) {
manifest, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
def, ok := manifest.Lookup("player.playback_speed")
if !ok {
t.Fatal("player.playback_speed is not in the manifest")
}
if def.ValueSchema.Step == nil {
t.Fatal("the manifest no longer declares a step; drop this check too")
}
step := *def.ValueSchema.Step
min, ok := def.ValueSchema.Minimum.Current()
if !ok {
t.Fatal("the manifest no longer declares a minimum for player.playback_speed")
}
// A value one half-step above the minimum must be rejected by the typed
// endpoint's validator for whatever step the manifest currently declares.
offStep := strconv.FormatFloat(min+step/2, 'f', -1, 64)
if err := def.ValueSchema.ValidateValue(json.RawMessage(offStep), nil); err == nil {
t.Errorf("%s is off the manifest's declared %g step but the contract accepted it", offStep, step)
}
}
+98 -202
View File
@@ -16,8 +16,6 @@ import (
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/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -74,35 +72,6 @@ type legacyAliasFailureStore struct {
failLegacyDelete bool
}
func (s legacyAliasFailureStore) WithPreferenceSettingsTransaction(
ctx context.Context, fn func(userstore.PreferenceSettingsWriter) error,
) error {
transactioner, ok := s.UserStore.(userstore.PreferenceSettingsTransactioner)
if !ok {
return errors.New("transactioner unavailable")
}
return transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
return fn(legacyAliasFailureWriter{
PreferenceSettingsWriter: tx,
failLegacyDelete: s.failLegacyDelete,
})
})
}
type legacyAliasFailureWriter struct {
userstore.PreferenceSettingsWriter
failLegacyDelete bool
}
func (w legacyAliasFailureWriter) DeleteDeviceSetting(
ctx context.Context, profileID, deviceID, key string,
) error {
if w.failLegacyDelete && key == legacyAndroidNextUpPromptSettingKey {
return errors.New("legacy delete failed")
}
return w.PreferenceSettingsWriter.DeleteDeviceSetting(ctx, profileID, deviceID, key)
}
func (s legacyAliasFailureStore) DeleteDeviceSetting(ctx context.Context, profileID, deviceID, key string) error {
if s.failLegacyDelete && key == legacyAndroidNextUpPromptSettingKey {
return errors.New("legacy delete failed")
@@ -527,7 +496,7 @@ func TestAndroidNextUpSettingAliasDoesNotUseOrDeleteUserRow(t *testing.T) {
}
func TestAndroidNextUpSettingAliasCleanupFailures(t *testing.T) {
t.Run("PUT rolls back when legacy cleanup fails", func(t *testing.T) {
t.Run("PUT succeeds when legacy cleanup fails", func(t *testing.T) {
baseStore := newProfileTestStore(t)
store := legacyAliasFailureStore{UserStore: baseStore, failLegacyDelete: true}
handler := NewSettingsHandler(testUserStoreProvider{store: store})
@@ -543,8 +512,8 @@ func TestAndroidNextUpSettingAliasCleanupFailures(t *testing.T) {
handler.HandleSetDeviceSetting(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
canonical, err := baseStore.GetDeviceSetting(
context.Background(), "profile-1", "android-tv", canonicalNextUpPromptSettingKey,
@@ -552,8 +521,8 @@ func TestAndroidNextUpSettingAliasCleanupFailures(t *testing.T) {
if err != nil {
t.Fatalf("GetDeviceSetting canonical: %v", err)
}
if canonical != nil {
t.Fatalf("legacy/canonical transaction partially committed: %#v", canonical)
if canonical == nil || canonical.Value != "60" {
t.Fatalf("canonical setting = %#v, want value 60", canonical)
}
})
@@ -599,137 +568,6 @@ func TestGenericSettingsRejectInvalidRegisteredValues(t *testing.T) {
}
}
func TestLegacyUserSettingMirrorsEveryCanonicalProfile(t *testing.T) {
store := newProfileTestStore(t)
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Guest"}); err != nil {
t.Fatalf("CreateProfile: %v", err)
}
handler := NewSettingsHandler(testUserStoreProvider{store: store})
send := func(method string, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/settings/search.media_scope", bytes.NewReader(body))
req = withRouteParams(req, map[string]string{"key": searchMediaScopeSettingKey})
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}))
rec := httptest.NewRecorder()
if method == http.MethodPut {
handler.HandleSetSetting(rec, req)
} else {
handler.HandleDeleteSetting(rec, req)
}
return rec
}
if rec := send(http.MethodPut, []byte(`{"value":"audiobook"}`)); rec.Code != http.StatusNoContent {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
for _, profileID := range []string{"profile-1", "profile-2"} {
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: searchMediaScopeSettingKey, Scope: settingscontract.ScopeProfile, ProfileID: profileID,
})
if err != nil || value == nil || string(value.Value) != `"audiobook"` {
t.Fatalf("canonical %s value = %+v, err=%v", profileID, value, err)
}
}
if rec := send(http.MethodDelete, nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
for _, profileID := range []string{"profile-1", "profile-2"} {
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: searchMediaScopeSettingKey, Scope: settingscontract.ScopeProfile, ProfileID: profileID,
})
if err != nil || value != nil {
t.Fatalf("canonical %s survived delete: %+v, err=%v", profileID, value, err)
}
}
}
func TestLegacyDeviceSettingsMirrorCanonicalRows(t *testing.T) {
store := newProfileTestStore(t)
handler := NewSettingsHandler(testUserStoreProvider{store: store})
send := func(method, key string, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/settings/device/"+key, bytes.NewReader(body))
req = withRouteParams(req, map[string]string{"key": key})
req.Header.Set(deviceIDHeader, "living-room")
req = req.WithContext(apimw.SetProfileID(
apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
rec := httptest.NewRecorder()
if method == http.MethodPut {
handler.HandleSetDeviceSetting(rec, req)
} else {
handler.HandleDeleteDeviceSetting(rec, req)
}
return rec
}
canonical := func(key string) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "living-room",
})
if err != nil {
t.Fatalf("GetSettingValue(%s): %v", key, err)
}
return value
}
appearance := `{"fontSize":"large"}`
body, _ := json.Marshal(setSettingRequest{Value: appearance})
if rec := send(http.MethodPut, subtitleAppearanceSettingKey, body); rec.Code != http.StatusNoContent {
t.Fatalf("appearance PUT = %d: %s", rec.Code, rec.Body.String())
}
if value := canonical("playback.subtitle_appearance"); value == nil || string(value.Value) != appearance {
t.Fatalf("canonical appearance = %+v", value)
}
if rec := send(http.MethodPut, "playback.preferred_quality", []byte(`{"value":"1080p-high"}`)); rec.Code != http.StatusNoContent {
t.Fatalf("quality PUT = %d: %s", rec.Code, rec.Body.String())
}
if value := canonical("playback.preferred_quality"); value == nil || string(value.Value) != `"1080p"` {
t.Fatalf("canonical quality = %+v", value)
}
if value := canonical("playback.max_bitrate_kbps"); value == nil || string(value.Value) != `10000` {
t.Fatalf("canonical bitrate = %+v", value)
}
if rec := send(http.MethodPut, "playback.preferred_quality", []byte(`{"value":"auto"}`)); rec.Code != http.StatusNoContent {
t.Fatalf("quality auto PUT = %d: %s", rec.Code, rec.Body.String())
}
if value := canonical("playback.max_bitrate_kbps"); value != nil {
t.Fatalf("stale bitrate survived auto: %+v", value)
}
if rec := send(http.MethodDelete, "playback.preferred_quality", nil); rec.Code != http.StatusNoContent {
t.Fatalf("quality DELETE = %d: %s", rec.Code, rec.Body.String())
}
if value := canonical("playback.preferred_quality"); value != nil {
t.Fatalf("quality survived delete: %+v", value)
}
}
func TestLegacyLooseJSONSettingPreservesSuccessfulStatus(t *testing.T) {
store := newProfileTestStore(t)
handler := NewSettingsHandler(testUserStoreProvider{store: store})
req := httptest.NewRequest(http.MethodPut, "/settings/device/"+libraryPageStateSettingKey,
bytes.NewReader([]byte(`{"value":"{}"}`)))
req = withRouteParams(req, map[string]string{"key": libraryPageStateSettingKey})
req.Header.Set(deviceIDHeader, "browser-1")
req = req.WithContext(apimw.SetProfileID(
apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
rec := httptest.NewRecorder()
handler.HandleSetDeviceSetting(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("legacy-valid JSON status = %d, want 204: %s", rec.Code, rec.Body.String())
}
legacy, err := store.GetDeviceSetting(context.Background(), "profile-1", "browser-1", libraryPageStateSettingKey)
if err != nil || legacy == nil || legacy.Value != "{}" {
t.Fatalf("legacy row = %+v, err=%v", legacy, err)
}
canonical, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: libraryPageStateSettingKey, Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "browser-1",
})
if err != nil || canonical != nil {
t.Fatalf("unrepresentable canonical row = %+v, err=%v", canonical, err)
}
}
func TestLibraryPageStateIsDeviceScopedJSONSetting(t *testing.T) {
store := newProfileTestStore(t)
handler := NewSettingsHandler(testUserStoreProvider{store: store})
@@ -853,6 +691,53 @@ func TestRememberLibraryPageStateIsDeviceScopedBoolSetting(t *testing.T) {
}
}
func TestAdminCanResetSubtitleAppearanceDeviceOverrides(t *testing.T) {
store := newProfileTestStore(t)
for _, deviceID := range []string{"apple-tv", "iphone"} {
if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{
ProfileID: "profile-1",
DeviceID: deviceID,
Key: subtitleAppearanceSettingKey,
Value: `{"fontSize":"small"}`,
}); err != nil {
t.Fatalf("SetDeviceSetting(%s): %v", deviceID, err)
}
}
handler := &AdminHandler{storeProv: testUserStoreProvider{store: store}}
req := httptest.NewRequest(http.MethodDelete, "/admin/users/7/profiles/profile-1/device-settings/subtitle_appearance/apple-tv", nil)
req = withRouteParams(req, map[string]string{
"id": "7", "profile_id": "profile-1", "key": subtitleAppearanceSettingKey, "device_id": "apple-tv",
})
rec := httptest.NewRecorder()
handler.HandleDeleteUserDeviceSetting(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete one status = %d body=%s", rec.Code, rec.Body.String())
}
remaining, err := store.ListDeviceSettings(context.Background(), subtitleAppearanceSettingKey)
if err != nil {
t.Fatalf("ListDeviceSettings: %v", err)
}
if len(remaining) != 1 || remaining[0].DeviceID != "iphone" {
t.Fatalf("remaining = %#v", remaining)
}
req = httptest.NewRequest(http.MethodDelete, "/admin/users/7/device-settings/subtitle_appearance", nil)
req = withRouteParams(req, map[string]string{"id": "7", "key": subtitleAppearanceSettingKey})
rec = httptest.NewRecorder()
handler.HandleDeleteUserDeviceSettingsByKey(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete all status = %d body=%s", rec.Code, rec.Body.String())
}
remaining, err = store.ListDeviceSettings(context.Background(), subtitleAppearanceSettingKey)
if err != nil {
t.Fatalf("ListDeviceSettings after delete all: %v", err)
}
if len(remaining) != 0 {
t.Fatalf("remaining after delete all = %#v", remaining)
}
}
func TestEffectiveSettingsAreIsolatedPerProfileOnSameDevice(t *testing.T) {
store := newProfileTestStore(t)
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Guest"}); err != nil {
@@ -934,15 +819,6 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
}); err != nil {
t.Fatalf("RegisterDevice store1: %v", err)
}
canonicalBedroom, err := store1.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: "playback.subtitle_mode",
Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1",
DeviceID: "bedroom",
}, json.RawMessage(`"always"`))
if err != nil {
t.Fatalf("UpsertSettingValue canonical bedroom override: %v", err)
}
if err := store2.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{
ProfileID: "profile-1",
DeviceID: "phone",
@@ -992,8 +868,7 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
if bedroom == nil {
t.Fatalf("registered device without overrides missing: %#v", listResp.Devices)
}
if bedroom.OverrideCount != 1 || bedroom.ProfileCount != 1 || bedroom.DeviceName != "Bedroom TV" ||
bedroom.LastUpdated != canonicalBedroom.UpdatedAt {
if bedroom.OverrideCount != 0 || bedroom.ProfileCount != 1 || bedroom.DeviceName != "Bedroom TV" {
t.Fatalf("registered device summary = %#v", bedroom)
}
@@ -1025,8 +900,7 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
if err := json.NewDecoder(rec.Body).Decode(&detailResp); err != nil {
t.Fatalf("decode registered detail: %v", err)
}
if detailResp.DeviceName != "Bedroom TV" || detailResp.OverrideCount != 1 ||
detailResp.LastUpdated != canonicalBedroom.UpdatedAt {
if detailResp.DeviceName != "Bedroom TV" || detailResp.OverrideCount != 0 {
t.Fatalf("registered detail response = %#v", detailResp)
}
if len(detailResp.Settings) != 0 {
@@ -1037,29 +911,51 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
}
}
func TestAdminDeviceSummaryDeduplicatesMirroredLegacyAlias(t *testing.T) {
for name, keys := range map[string][2]string{
"subtitle appearance": {subtitleAppearanceSettingKey, settingskeys.PlaybackSubtitleAppearance},
"theme": {"ui_theme", "ui.theme"},
func TestAdminCanResetAllOverridesForOneDevice(t *testing.T) {
store := newProfileTestStore(t)
for _, entry := range []userstore.DeviceSettingEntry{
{ProfileID: "profile-1", DeviceID: "living-room", Key: "player.playback_speed", Value: "1.25"},
{ProfileID: "profile-1", DeviceID: "living-room", Key: "player.audio_sync_ms", Value: "120"},
{ProfileID: "profile-1", DeviceID: "phone", Key: "player.hdr_enabled", Value: "false"},
} {
t.Run(name, func(t *testing.T) {
summaries := buildAdminDeviceSummaries(7, "user", "user@example.com",
[]userstore.DeviceSettingEntry{{
ProfileID: "profile-1", DeviceID: "living-room", Key: keys[0],
UpdatedAt: "2026-07-30T01:00:00Z",
}},
[]userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: keys[1], Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "living-room",
},
UpdatedAt: "2026-07-30T01:00:01Z",
}}, nil, map[string]string{"profile-1": "Main"})
if len(summaries) != 1 || summaries[0].OverrideCount != 1 ||
len(summaries[0].Profiles) != 1 || summaries[0].Profiles[0].OverrideCount != 1 {
t.Fatalf("mirrored alias summaries = %#v", summaries)
}
})
if err := store.SetDeviceSetting(context.Background(), entry); err != nil {
t.Fatalf("SetDeviceSetting: %v", err)
}
}
handler := &AdminHandler{storeProv: testUserStoreProvider{store: store}}
req := httptest.NewRequest(http.MethodDelete, "/admin/users/7/profiles/profile-1/devices/living-room/settings", nil)
req = withRouteParams(req, map[string]string{"id": "7", "profile_id": "profile-1", "device_id": "living-room"})
rec := httptest.NewRecorder()
handler.HandleDeleteAllUserDeviceSettings(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d body=%s", rec.Code, rec.Body.String())
}
entries, err := store.ListAllDeviceSettings(context.Background())
if err != nil {
t.Fatalf("ListAllDeviceSettings: %v", err)
}
if len(entries) != 1 || entries[0].DeviceID != "phone" {
t.Fatalf("entries after delete = %#v", entries)
}
registry, ok := store.(userstore.DeviceRegistry)
if !ok {
t.Fatalf("store does not support device registry")
}
devices, err := registry.ListDevices(context.Background())
if err != nil {
t.Fatalf("ListDevices: %v", err)
}
foundLivingRoom := false
for _, device := range devices {
if device.ProfileID == "profile-1" && device.DeviceID == "living-room" {
foundLivingRoom = true
break
}
}
if !foundLivingRoom {
t.Fatalf("registry devices after delete = %#v", devices)
}
}
@@ -1,88 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
// TestLegacySettingsAPIRejectsJellycompatKeys pins the removal of the
// jellycompat carve-out: DisplayPreferences blobs live in their own table now,
// so the legacy settings endpoints treat jellycompat:* like any other unknown
// key — refused on read and write, and never surfaced by the list.
func TestLegacySettingsAPIRejectsJellycompatKeys(t *testing.T) {
const jellycompatKey = "jellycompat:displayprefs:usersettings:emby"
store := newProfileTestStore(t)
handler := NewSettingsHandler(testUserStoreProvider{store: store})
authed := func(req *http.Request) *http.Request {
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess})
return req.WithContext(ctx)
}
t.Run("write and read are refused", func(t *testing.T) {
cases := []struct {
method string
body string
serve http.HandlerFunc
}{
{http.MethodPut, `{"value":"{}"}`, handler.HandleSetSetting},
{http.MethodGet, "", handler.HandleGetSetting},
{http.MethodDelete, "", handler.HandleDeleteSetting},
}
for _, tc := range cases {
req := httptest.NewRequest(tc.method, "/settings/"+jellycompatKey, strings.NewReader(tc.body))
req = withProfileRouteParam(authed(req), "key", jellycompatKey)
rec := httptest.NewRecorder()
tc.serve(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s status = %d, want %d (body=%s)",
tc.method, rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
})
t.Run("the list never surfaces a leftover row", func(t *testing.T) {
// A row that predates the move migration must be invisible, not leaked.
if err := store.SetSetting(context.Background(), jellycompatKey, `{"SortBy":"SortName"}`); err != nil {
t.Fatalf("seeding leftover row: %v", err)
}
// Positive control so an empty list cannot pass vacuously.
if err := store.SetSetting(context.Background(), dateFormatSettingKey, "auto"); err != nil {
t.Fatalf("seeding registered setting: %v", err)
}
req := authed(httptest.NewRequest(http.MethodGet, "/settings", nil))
rec := httptest.NewRecorder()
handler.HandleListSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list status = %d body=%s", rec.Code, rec.Body.String())
}
var resp settingsListResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
sawControl := false
for _, entry := range resp.Settings {
if strings.HasPrefix(entry.Key, "jellycompat:") {
t.Errorf("list surfaced %s", entry.Key)
}
if entry.Key == dateFormatSettingKey {
sawControl = true
}
}
if !sawControl {
t.Errorf("list omitted the registered control key %s; response=%+v", dateFormatSettingKey, resp)
}
})
}
File diff suppressed because it is too large Load Diff
@@ -1,141 +0,0 @@
package handlers
import (
"net/http"
"strings"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// Admin projections of the canonical settings API. These replace the string
// registry's /admin/users/{id}/settings* and device-settings* routes with the
// same typed surface clients use on /settings/values: the same validation, the
// same scopes, the same response shapes. The only differences are that the
// target user comes from the path instead of the session, and that profile and
// device ids come from the query string — an admin has no session claim to the
// user they are inspecting.
//
// Mounted behind requireActingAdmin next to the other /admin/users routes, so
// authorization is the router group's, not re-checked here.
// HandleAdminListUserSettingValues handles
// GET /admin/users/{id}/settings/values: every explicit value the target user
// has stored, across all scopes. It deliberately lists stored rows rather than
// resolving: the admin surface answers "what overrides exist" (and offers a
// reset per row), which is the same question the session route's per-scope GET
// answers for one identity.
func (h *SettingValuesHandler) HandleAdminListUserSettingValues(w http.ResponseWriter, r *http.Request) {
store, _, ok := h.adminTargetStore(w, r)
if !ok {
return
}
values, err := store.ListAllSettingValues(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings")
return
}
out := make([]settingValueResponse, 0, len(values))
for _, value := range values {
out = append(out, settingValueToResponse(value))
}
writeJSON(w, http.StatusOK, map[string]any{
fieldValues: out,
fieldRevision: h.contract.Revision,
})
}
// HandleAdminSetUserSettingValue handles
// PUT /admin/users/{id}/settings/values/{key}: write an explicit value at one
// scope on behalf of the target user, through the same validation and
// idempotency path as the session route.
func (h *SettingValuesHandler) HandleAdminSetUserSettingValue(w http.ResponseWriter, r *http.Request) {
store, userID, ok := h.adminTargetStore(w, r)
if !ok {
return
}
identity, ok := h.adminIdentityFromRequest(w, r)
if !ok {
return
}
// The session route's profile is validated by middleware; the admin names
// one in the query, so its existence is checked here. Postgres would refuse
// an orphan row on its profile FK anyway — checking first turns that 500
// into a 404 and gives SQLite the same behavior.
if identity.ProfileID != "" && !adminProfileExists(w, r, store, identity.ProfileID) {
return
}
h.setValueAt(w, r, store, userID, identity)
}
// HandleAdminDeleteUserSettingValue handles
// DELETE /admin/users/{id}/settings/values/{key}: remove the target user's
// explicit value at one scope so inheritance applies again.
func (h *SettingValuesHandler) HandleAdminDeleteUserSettingValue(w http.ResponseWriter, r *http.Request) {
store, userID, ok := h.adminTargetStore(w, r)
if !ok {
return
}
identity, ok := h.adminIdentityFromRequest(w, r)
if !ok {
return
}
h.deleteValueAt(w, r, store, userID, identity)
}
// adminTargetStore resolves the {id} path parameter to the target user's
// store.
func (h *SettingValuesHandler) adminTargetStore(
w http.ResponseWriter, r *http.Request,
) (userstore.UserStore, int, bool) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return nil, 0, false
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return nil, 0, false
}
if store == nil {
writeError(w, http.StatusNotFound, "not_found", "User store not found")
return nil, 0, false
}
return store, userID, true
}
// adminIdentityFromRequest is identityFromRequest with the profile and device
// taken from the query string instead of the session: the admin is not the
// user being addressed, so there are no session headers to trust. Everything
// after that — content-scope ids, identity validation, the contract's scope
// allowance — is the shared completeIdentity path.
func (h *SettingValuesHandler) adminIdentityFromRequest(
w http.ResponseWriter, r *http.Request,
) (userstore.SettingIdentity, bool) {
key, scope, ok := h.keyedScopeFromRequest(w, r)
if !ok {
return userstore.SettingIdentity{}, false
}
query := r.URL.Query()
identity := userstore.SettingIdentity{Key: key, Scope: scope}
if scope != settingscontract.ScopeAccount {
identity.ProfileID = strings.TrimSpace(query.Get("profile_id"))
if identity.ProfileID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"profile_id is required for this scope")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileDevice {
identity.DeviceID = strings.TrimSpace(query.Get("device_id"))
if identity.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"device_id is required for a device override")
return userstore.SettingIdentity{}, false
}
}
return h.completeIdentity(w, r.Context(), query, identity)
}
@@ -1,372 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
const (
adminValuesAdminID = 1
adminValuesTargetID = 7
)
// adminValuesEnv mounts the admin projection exactly as the router does: the
// canonical handler behind RequireActingAdmin, with the target user's store
// distinct from the admin's own so a route that resolved the wrong user is
// caught rather than masked by a shared store.
type adminValuesEnv struct {
router chi.Router
handler *SettingValuesHandler
adminStore userstore.UserStore
targetStore userstore.UserStore
}
func newAdminValuesEnv(t *testing.T) adminValuesEnv {
t.Helper()
adminStore := newIsolatedProfileTestStore(t, "admin")
targetStore := newIsolatedProfileTestStore(t, "target")
contract, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
handler := NewSettingValuesHandler(mappedTestUserStoreProvider{
stores: map[int]userstore.UserStore{
adminValuesAdminID: adminStore,
adminValuesTargetID: targetStore,
},
}, contract)
router := chi.NewRouter()
router.Group(func(r chi.Router) {
r.Use(apimw.RequireActingAdmin(nil))
r.Get("/admin/users/{id}/settings/values", handler.HandleAdminListUserSettingValues)
r.Put("/admin/users/{id}/settings/values/{key}", handler.HandleAdminSetUserSettingValue)
r.Delete("/admin/users/{id}/settings/values/{key}", handler.HandleAdminDeleteUserSettingValue)
})
return adminValuesEnv{router: router, handler: handler, adminStore: adminStore, targetStore: targetStore}
}
// do sends a request through the mounted routes as a caller with the given
// role; an empty role sends no session at all.
func (env adminValuesEnv) do(t *testing.T, role, method, target string, body []byte) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body == nil {
req = httptest.NewRequest(method, target, nil)
} else {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
}
if role != "" {
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: adminValuesAdminID, Role: role}))
}
rec := httptest.NewRecorder()
env.router.ServeHTTP(rec, req)
return rec
}
func TestAdminSettingValuesRefuseNonAdmins(t *testing.T) {
env := newAdminValuesEnv(t)
for name, req := range map[string]struct {
method, target string
body []byte
}{
"list": {http.MethodGet, "/admin/users/7/settings/values", nil},
"set": {http.MethodPut, "/admin/users/7/settings/values/playback.subtitle_mode?scope=account", []byte(`{"value":"always"}`)},
"delete": {http.MethodDelete, "/admin/users/7/settings/values/playback.subtitle_mode?scope=account", nil},
} {
t.Run(name, func(t *testing.T) {
if rec := env.do(t, "user", req.method, req.target, req.body); rec.Code != http.StatusForbidden {
t.Errorf("non-admin %s = %d, want 403: %s", name, rec.Code, rec.Body.String())
}
if rec := env.do(t, "", req.method, req.target, req.body); rec.Code != http.StatusUnauthorized {
t.Errorf("anonymous %s = %d, want 401: %s", name, rec.Code, rec.Body.String())
}
})
}
}
func TestAdminSettingValuesRejectNonexistentLibraryContext(t *testing.T) {
env := newAdminValuesEnv(t)
env.handler.SetLibraryLookup(settingValuesLibraryLookup{existing: map[int]bool{7: true}})
for _, method := range []string{http.MethodPut, http.MethodDelete} {
var body []byte
if method == http.MethodPut {
body = []byte(`{"value":"de"}`)
}
rec := env.do(t, "admin", method,
"/admin/users/7/settings/values/playback.subtitle_language?scope=profile_library&profile_id=profile-1&library_id=99",
body)
if rec.Code != http.StatusNotFound {
t.Errorf("%s nonexistent library = %d, want 404: %s", method, rec.Code, rec.Body.String())
}
}
}
func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
env := newAdminValuesEnv(t)
ctx := context.Background()
seeded := map[settingscontract.Scope]userstore.SettingIdentity{
settingscontract.ScopeAccount: {
Key: "catalog.metadata_language", Scope: settingscontract.ScopeAccount,
},
settingscontract.ScopeProfile: {
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
},
settingscontract.ScopeProfileDevice: {
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "tv-1",
},
settingscontract.ScopeProfileLibrary: {
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileLibrary,
ProfileID: "profile-1", LibraryID: 42,
},
settingscontract.ScopeProfileSeries: {
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileSeries,
ProfileID: "profile-1", SeriesID: "s-1",
},
}
values := map[settingscontract.Scope]string{
settingscontract.ScopeAccount: `"de"`,
settingscontract.ScopeProfile: `"always"`,
settingscontract.ScopeProfileDevice: `"en"`,
settingscontract.ScopeProfileLibrary: `"fr"`,
settingscontract.ScopeProfileSeries: `"ja"`,
}
for scope, id := range seeded {
if _, err := env.targetStore.UpsertSettingValue(ctx, id, json.RawMessage(values[scope])); err != nil {
t.Fatalf("seeding %s: %v", scope, err)
}
}
// A value in the admin's own store must not leak into the target's list.
if _, err := env.adminStore.UpsertSettingValue(ctx, userstore.SettingIdentity{
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeAccount,
}, json.RawMessage(`"off"`)); err != nil {
t.Fatalf("seeding admin store: %v", err)
}
rec := env.do(t, "admin", http.MethodGet, "/admin/users/7/settings/values", nil)
if rec.Code != http.StatusOK {
t.Fatalf("list = %d: %s", rec.Code, rec.Body.String())
}
var body struct {
Values []settingValueResponse `json:"values"`
Revision int `json:"revision"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
if len(body.Values) != len(seeded) {
t.Fatalf("listed %d values, want %d: %s", len(body.Values), len(seeded), rec.Body.String())
}
contract, _ := settingscontract.Load()
if body.Revision != contract.Revision {
t.Errorf("revision = %d, want %d", body.Revision, contract.Revision)
}
for _, got := range body.Values {
want, ok := seeded[settingscontract.Scope(got.Scope)]
if !ok {
t.Errorf("unexpected scope %q in list", got.Scope)
continue
}
if got.Key != want.Key || got.ProfileID != want.ProfileID ||
got.DeviceID != want.DeviceID || got.LibraryID != want.LibraryID ||
got.SeriesID != want.SeriesID {
t.Errorf("listed identity at %s = %+v, want %+v", got.Scope, got, want)
}
if string(got.Value) != values[settingscontract.Scope(got.Scope)] {
t.Errorf("value at %s = %s, want %s", got.Scope, got.Value, values[settingscontract.Scope(got.Scope)])
}
}
// A user with no store is a 404, not an empty list pretending to be truth.
if rec := env.do(t, "admin", http.MethodGet, "/admin/users/99/settings/values", nil); rec.Code != http.StatusNotFound {
t.Errorf("list for unknown user = %d, want 404", rec.Code)
}
}
func TestAdminSetAndDeleteAtExplicitScopeRoundTrip(t *testing.T) {
env := newAdminValuesEnv(t)
ctx := context.Background()
target := "/admin/users/7/settings/values/playback.subtitle_language" +
"?scope=profile_device&profile_id=profile-1&device_id=tv-1"
identity := userstore.SettingIdentity{
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "tv-1",
}
rec := env.do(t, "admin", http.MethodPut, target, []byte(`{"value":"de"}`))
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) != `"de"` || stored.Scope != "profile_device" ||
stored.ProfileID != "profile-1" || stored.DeviceID != "tv-1" {
t.Errorf("PUT stored %+v, want \"de\" at profile-1/tv-1", stored)
}
// The write landed in the target user's store and only there.
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil {
t.Fatalf("target store value = %+v, %v; want stored", got, err)
}
if got, err := env.adminStore.GetSettingValue(ctx, identity); err != nil || got != nil {
t.Errorf("admin store value = %+v, %v; want none", got, err)
}
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got != nil {
t.Errorf("value after delete = %+v, %v; want gone", got, err)
}
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusNotFound {
t.Errorf("second DELETE = %d, want 404", rec.Code)
}
}
// TestAdminSetRejectsInvalidValueLikeTheSessionRoute pins that the admin write
// is the same validation path as /settings/values, not a second validator: an
// invalid value fails with the identical status, code and message.
func TestAdminSetRejectsInvalidValueLikeTheSessionRoute(t *testing.T) {
env := newAdminValuesEnv(t)
invalid := []byte(`{"value":"sideways"}`)
adminRec := env.do(t, "admin", http.MethodPut,
"/admin/users/7/settings/values/playback.subtitle_mode?scope=profile&profile_id=profile-1", invalid)
if adminRec.Code != http.StatusBadRequest {
t.Fatalf("admin PUT = %d, want 400: %s", adminRec.Code, adminRec.Body.String())
}
// The same write through the session route, as the target user.
sessionReq := httptest.NewRequest(http.MethodPut,
"/settings/values/playback.subtitle_mode?scope=profile", bytes.NewReader(invalid))
sessionCtx := apimw.SetClaims(sessionReq.Context(), &auth.Claims{UserID: adminValuesTargetID})
sessionReq = sessionReq.WithContext(apimw.SetProfileID(sessionCtx, "profile-1"))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", "playback.subtitle_mode")
sessionReq = sessionReq.WithContext(context.WithValue(sessionReq.Context(), chi.RouteCtxKey, routeCtx))
sessionRec := httptest.NewRecorder()
env.handler.HandleSetValue(sessionRec, sessionReq)
if sessionRec.Code != adminRec.Code {
t.Errorf("status: session %d, admin %d", sessionRec.Code, adminRec.Code)
}
var adminErr, sessionErr errorResponse
if err := json.Unmarshal(adminRec.Body.Bytes(), &adminErr); err != nil {
t.Fatalf("decoding admin error: %v", err)
}
if err := json.Unmarshal(sessionRec.Body.Bytes(), &sessionErr); err != nil {
t.Fatalf("decoding session error: %v", err)
}
if adminErr.Error != "invalid_value" {
t.Errorf("admin error code = %q, want invalid_value", adminErr.Error)
}
if adminErr != sessionErr {
t.Errorf("error bodies differ: admin %+v, session %+v", adminErr, sessionErr)
}
}
func TestAdminSetRefusesUnknownKeysAndProfiles(t *testing.T) {
env := newAdminValuesEnv(t)
rec := env.do(t, "admin", http.MethodPut,
"/admin/users/7/settings/values/totally.invented.key?scope=profile&profile_id=profile-1",
[]byte(`{"value":"x"}`))
if rec.Code != http.StatusNotFound {
t.Errorf("unknown key = %d, want 404: %s", rec.Code, rec.Body.String())
}
var unknownErr errorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &unknownErr); err != nil {
t.Fatalf("decoding unknown-key error: %v", err)
}
if unknownErr.Error != "unknown_setting" {
t.Errorf("unknown key code = %q, want unknown_setting", unknownErr.Error)
}
// A client_local key is refused as server storage, same as the session route.
rec = env.do(t, "admin", http.MethodPut,
"/admin/users/7/settings/values/downloads.wifi_only?scope=profile&profile_id=profile-1",
[]byte(`{"value":true}`))
if rec.Code != http.StatusBadRequest {
t.Errorf("client_local key = %d, want 400: %s", rec.Code, rec.Body.String())
}
// A profile the target user does not have is a 404, which also keeps
// Postgres's profile FK from turning the typo into a 500.
rec = env.do(t, "admin", http.MethodPut,
"/admin/users/7/settings/values/playback.subtitle_mode?scope=profile&profile_id=ghost",
[]byte(`{"value":"always"}`))
if rec.Code != http.StatusNotFound {
t.Errorf("unknown profile = %d, want 404: %s", rec.Code, rec.Body.String())
}
}
// TestAdminMutationsAttributeEventsToTheTargetUser pins the admin-specific
// half of the change-event contract: the envelope is addressed to the user
// whose settings moved — the target named in the path — never to the acting
// admin. Addressing the admin instead would leave the target's devices stale
// on exactly the change they most need to hear about, while poking the
// admin's own devices for nothing. The acting admin (user 1) and the target
// (user 7) are distinct here precisely so the two attributions cannot alias.
func TestAdminMutationsAttributeEventsToTheTargetUser(t *testing.T) {
env := newAdminValuesEnv(t)
env.handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{})
events, unsubscribe := env.handler.EventsHub.Subscribe()
defer unsubscribe()
target := "/admin/users/7/settings/values/playback.subtitle_mode?scope=profile&profile_id=profile-1"
assertTargetEnvelope := func(operation string) {
t.Helper()
var envelope evt.Envelope
select {
case envelope = <-events:
default:
t.Fatalf("admin %s published no event", operation)
}
if envelope.UserID != adminValuesTargetID {
t.Errorf("admin %s event addressed to user %d, want the target %d",
operation, envelope.UserID, adminValuesTargetID)
}
if envelope.UserID == adminValuesAdminID {
t.Errorf("admin %s event addressed to the acting admin", operation)
}
if envelope.ProfileID != "profile-1" {
t.Errorf("admin %s event profile = %q, want profile-1", operation, envelope.ProfileID)
}
if envelope.Channel != evt.ChannelUserSettings || envelope.Event != userSettingsChangedEvent {
t.Errorf("admin %s published %s on %s, want %s on %s",
operation, envelope.Event, envelope.Channel,
userSettingsChangedEvent, evt.ChannelUserSettings)
}
}
if rec := env.do(t, "admin", http.MethodPut, target, []byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
assertTargetEnvelope("PUT")
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
assertTargetEnvelope("DELETE")
}

Some files were not shown because too many files have changed in this diff Show More