Compare commits

..
Author SHA1 Message Date
Claude Code 6a50097f27 fix(api): use shared UserLoader for transcription quota exemption
Adapts the rebased subtitle ASR quota feature to the group-based admin
model: quotaExempt checks admin status server-side via isAdminRequest
instead of the removed claims role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:25:44 +02:00
Claude Code 5c950beefe fix(auth): revalidate websocket viewers and enforce enabled on compat sessions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:49 +02:00
Claude Code 767a7ccf24 refactor: consolidate user loaders, group scans, and policy formatting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:49 +02:00
Claude Code 20ed9ecd05 fix(auth): prevent disabling or deleting the last enabled administrator
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 43beb4693a docs: align spec with implemented hydration and response shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 5f9cfd213b docs(jellycompat): update stale users.library_ids comment to group-derived access
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 40c4d6bcc4 chore(api): drop validation for orphaned defaults.* settings keys
Nothing reads defaults.max_playback_quality or defaults.max_profiles
anymore; new-user policy comes from groups and the only remaining
defaults.* consumer is defaults.card_overlays. Also extract role and
issuer-kind string constants flagged by goconst (presentation role
strings stay distinct from auth Permission values on purpose).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 076351f3d5 fix(web): omit group_ids until groups load so backend defaults apply
When creating a new user, omit group_ids from the request body if the
admin never touched the selector AND the groups query hasn't successfully
resolved (still loading or errored). This lets the backend's
default-group provisioning apply rather than receiving an empty array
and producing a locked-out zero-group user. When groups have loaded and
the preselection is untouched, the explicit selection is still sent.
The Groups tab now shows skeleton rows while loading and a muted notice
on error instead of a silent empty selector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 3034db6dc8 feat(web): group membership controls on user admin pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 4430eda7a2 test(auth): make tampered-token test deterministic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 373939126e fix(auth): unlimited (0) group limits win the policy union
Enforcement treats 0 as unlimited for max_streams, max_transcodes
(playback session manager), and max_profiles (profile creation), so a
plain max() across groups inverted the most-permissive union: a user in
{max_streams: 5} and {max_streams: 0} resolved to 5. Fold limits with
mergeLimit instead, seeding from the first group so an explicit 0 sticks.

Zero-group lockout stays enforced via the empty library list; MaxProfiles
is floored at 1 there because 0 would grant unlimited profile creation.
Admins keep merged group limits, matching the pre-group behavior where
admins had no stream-limit bypass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 49e14d3459 feat(web): admin groups management page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code c26835cd6a feat(web): group types and admin query hooks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 83a9a1c102 fix(auth): qualify group columns to fix ambiguous created_at in membership joins
GroupsForUser/GroupsForUsers join user_groups, which also has a created_at
column; the unqualified column list made every group hydration (and thus every
login) fail against a real Postgres with SQLSTATE 42702. Caught by live E2E
verification; unit tests use fakes and missed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code e40e2437ef fix(api): group member endpoints return correct errors for unknown user and group
AddMember now detects FK violations on user_id and returns ErrUnknownUser,
mapped to 404 unknown_user in writeGroupError. HandleListMembers calls
GetByID first so a nonexistent group yields 404 instead of 200 with empty
members. Adds handler tests for both cases plus a PATCH permissions
semantics table test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 5d513c2737 feat(api): admin group management endpoints
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 6701da1829 fix(jellycompat): load fresh user for policy; enforce download permission
Remove the session policy bridge (IsAdmin/DownloadAllowed/LibraryIDs cached
on Session): the fields were never persisted to jellycompat_sessions, so any
restart zeroed them (admins rendered as non-admin, restricted users as
unrestricted), and group edits never refreshed them. Policy is now always
read from a freshly loaded user.

- userDTO loads the user via a userLoader (hydrated effective policy) and
  fails the request on load error instead of falling back to permissive
  defaults; login, /Users/Me, /Users, /Users/{id} are cold paths.
- Rename the apiKeyUserLoader interface/Dependencies field to userLoader/
  UserLoader since it now also backs user DTOs and download enforcement.
- HandleDownload enforces the group-derived DownloadAllowed policy with a
  fresh user load, returning 403 for restricted users and failing closed on
  load errors; allowed-user behavior is unchanged (Infuse depends on it).
- Pin the EnabledFolders GUID encoding in TestUserPolicyFromEffectivePolicy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
rxwatcherandClaude Code fcc782fe19 feat(jellycompat): derive UserPolicy from group-based effective policy
Replace hardcoded IsAdministrator/EnableContentDownloading/EnableAllFolders
literals in userDTO with a buildUserPolicy helper that maps models.User's
group-derived effective policy (IsAdmin, DownloadAllowed, LibraryIDs) onto
the Jellyfin UserPolicy shape. Library IDs are encoded as Jellyfin folder
GUIDs via the existing EncodeNumericID(EncodedIDLibrary, ...) helper. Session
policy fields are populated at login and API-key session synthesis so no
per-request DB round-trip is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code 18bc4b3f16 test(api): cover RequireAdmin middleware denial and stash paths
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code ac280ab7f3 feat(auth): server-side admin checks; remove role from JWT claims
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code f499d0901b fix(auth): atomic user update and setup, default-group fallback, unknown-group mapping
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 2bf4bd127e feat(auth): users derive effective policy from groups
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code f2c9468122 refactor(auth): normalize before admin guard, dedupe group scan
In Update, call NormalizePermissions once up-front and run the
administrators admin-permission guard against the normalized slice so
whitespace-padded inputs like [" admin "] are no longer wrongly
rejected with ErrAdminPermRequired.

In GroupsForUsers, replace the hand-rolled 15-field Scan with the new
scanGroupFieldsPre helper (mirrors scanGroupFields but prepends extra
dest args before the group fields) so the scan list is no longer
duplicated in the codebase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 4b70679815 feat(auth): group repository with guards and revision bumps
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0b7156ac0a fix(auth): restore accurate column defaults and quality flatten in migration down path
- permissions column default on down path corrected to ARRAY['marker_edit']::text[]
  (matching the schema set by 183_marker_edit_audit.sql, not the bare '{}' default)
- max_playback_quality now flattened on down path via rank-prefixed MAX + split_part,
  mirroring the most-permissive-wins logic in internal/access/quality.go
- spec doc: align default_group_slugs → users.default_group_slugs to match the
  server_settings key used in the migration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 9c0172a706 feat(auth): group-based permissions schema migration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0733e3fc51 feat(auth): effective policy union resolver
Implements ApplyEffectivePolicy to compute a user's effective authorization
policy as the most-permissive union of their assigned groups. Handles special
cases for admin permissions, unrestricted library access, quality ceilings,
and download settings via table tests following TDD order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code ecc08b7569 feat(auth): group model, admin permission, IsAdmin derivation
- Add internal/models/group.go with Group, CreateGroupInput, UpdateGroupInput
  and built-in slug constants (administrators, users)
- Add IsAdmin bool and GroupIDs []int to models.User (derived fields, not DB columns)
- Add PermissionAdmin to assignablePermissions so groups can carry it
- Replace user.Role == "admin" checks in HasEffectivePermission / EffectivePermissions
  with user.IsAdmin (interim: scanUser/scanUsers set IsAdmin = Role == "admin")
- Update permissions_test.go: add IsAdmin: true alongside Role: "admin" in existing
  test, add TestAdminPermissionIsAssignable and TestIsAdminGrantsAllPermissions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0638a514b7 feat(access): add MaxQuality for most-permissive quality merge 2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code e067041b05 docs: group-based permissions implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code d934bb8924 docs: fix group migration to not erase outlier restrictions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 4d98005a23 docs: group-based tiered security model design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
222 changed files with 8243 additions and 21495 deletions
+20 -119
View File
@@ -69,9 +69,7 @@ import (
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/pluginhost"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/proxy"
"github.com/Silo-Server/silo-server/internal/push"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
@@ -122,7 +120,7 @@ func resolvePluginCacheDir() string {
return filepath.Join(os.TempDir(), "silo-plugins")
}
func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
func buildBaseHandler(format string, level slog.Level) slog.Handler {
opts := &slog.HandlerOptions{Level: level}
if strings.EqualFold(format, "json") {
return slog.NewJSONHandler(os.Stderr, opts)
@@ -130,19 +128,6 @@ func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
return slog.NewTextHandler(os.Stderr, opts)
}
func parseLogLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func mustGetSetting(store interface {
Get(context.Context, string) (string, error)
}, ctx context.Context, key, fallback string) string {
@@ -159,7 +144,8 @@ func configureOperationalLogging(
settingsRepo catalog.SettingsStore,
redisCfg config.RedisConfig,
logStreamHub *logstream.Hub,
filteredHandler slog.Handler,
baseHandler slog.Handler,
logQuiet string,
nodeID string,
) (opslog.Writer, *opslog.Repo, *partman.Manager) {
if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil {
@@ -196,6 +182,7 @@ func configureOperationalLogging(
opsCaptureLevel = slog.LevelError
}
filteredHandler := logfilter.New(baseHandler, logQuiet)
slog.SetDefault(slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID)))
return operationalWriter, opslog.NewRepo(pool), opsPM
@@ -470,14 +457,20 @@ func main() {
log.Fatalf("config validation: %v", err)
}
// Step 10: Configure log level. The level var and quiet filter are
// shared with the operational-logging handler chain and hot-reloaded by
// the config watcher in integrated mode.
logLevelVar := new(slog.LevelVar)
logLevelVar.Set(parseLogLevel(cfg.Server.LogLevel))
baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar)
quietFilter := logfilter.New(baseHandler, cfg.Server.LogQuiet)
slog.SetDefault(slog.New(quietFilter))
// Step 10: Configure log level
var slogLevel slog.Level
switch strings.ToLower(cfg.Server.LogLevel) {
case "debug":
slogLevel = slog.LevelDebug
case "warn", "warning":
slogLevel = slog.LevelWarn
case "error":
slogLevel = slog.LevelError
default:
slogLevel = slog.LevelInfo
}
baseHandler := buildBaseHandler(cfg.Server.LogFormat, slogLevel)
slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet)))
mode := cfg.Server.Mode
maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode)
@@ -498,19 +491,8 @@ func main() {
log.Fatalf("realtime hub start: %v", err)
}
eventsHub := realtimeHub.EventsHub()
var presenceRegistry presence.Registry
if cfg.Redis.URL != "" {
if rc, err := cache.NewRedisClient(cfg.Redis); err == nil {
presenceRegistry = presence.NewRedisRegistry(rc, 60*time.Second)
} else {
slog.Warn("presence: redis unavailable, falling back to in-memory registry", "error", err)
}
}
if presenceRegistry == nil {
presenceRegistry = presence.NewMemoryRegistry()
}
scanRegistry := evt.NewScanRegistry()
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, quietFilter, nodeID)
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, baseHandler, cfg.Server.LogQuiet, nodeID)
defer func() {
if err := eventBus.Close(); err != nil {
slog.Warn("event bus close error", "error", err)
@@ -530,7 +512,6 @@ func main() {
Mode: cfg.Server.Mode,
DatabaseURL: cfg.Database.URL,
JFListen: cfg.JellyfinCompat.Listen,
RedisURL: bc.RedisURL,
}
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
if err := watcher.Start(appCtx); err != nil {
@@ -572,29 +553,6 @@ func main() {
return
}
// Hot-reload config watcher for integrated/api mode. Reloads on
// EventSettingsChanged (Redis) with a 60s poll fallback, so settings
// changes apply without restart even on Redis-less deployments. The
// watcher's config supersedes the startup snapshot from here on.
configWatcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, nodeconfig.BootstrapOverrides{
Listen: bc.Listen,
Mode: bc.Mode,
DatabaseURL: bc.DatabaseURL,
JFListen: bc.JFListen,
RedisURL: bc.RedisURL,
})
if err := configWatcher.Start(appCtx); err != nil {
log.Fatalf("config watcher start: %v", err)
}
cfg = configWatcher.Config()
// Apply server.log_level / server.log_quiet changes live. Both feed the
// shared level var and quiet filter inside the default logger chain.
configWatcher.OnChange(func(_, updated *config.Config) {
logLevelVar.Set(parseLogLevel(updated.Server.LogLevel))
quietFilter.SetQuiet(updated.Server.LogQuiet)
})
// Determine which components to initialize based on mode.
needsS3 := mode == "integrated" || mode == "api"
needsScanner := mode == "integrated" || mode == "api"
@@ -610,8 +568,6 @@ func main() {
deps := api.Dependencies{
Config: cfg,
LiveConfig: configWatcher.Config,
OnConfigChange: configWatcher.OnChange,
BootstrapSensitiveConfigured: bootstrapSensitiveConfigured,
BootstrapSensitiveValues: bootstrapSensitiveValues,
AppContext: appCtx,
@@ -632,37 +588,7 @@ func main() {
restartReqCh <- struct{}{}
return nil
},
OnServerSettingUpdated: func(_ context.Context, _, _ string) {
// Nudge the hot-reload watcher so same-process settings changes
// apply immediately even without Redis (the event bus is a no-op
// then, leaving only the 60s poll).
configWatcher.RequestReload()
},
}
// Notifications subsystem: store → service → materializer.
// pool is always non-nil here (proxy/transcode returned early above).
notificationsStore := notifications.NewRepository(pool)
notificationsSvc := notifications.NewService(notificationsStore, eventsHub)
notificationsMaterializer := notifications.NewMaterializer(eventsHub, notificationsSvc, notifications.NewContentResolverRepo(pool))
if err := notificationsMaterializer.Start(appCtx); err != nil {
log.Fatalf("notifications materializer start: %v", err)
}
deps.NotificationsService = notificationsSvc
deps.PresenceRegistry = presenceRegistry
// Push subsystem: store → config → enqueuer → transports → worker.
pushStore := push.NewStore(pool)
pushConfig := push.NewConfig(settingsRepo)
notificationsSvc.SetPushEnqueuer(push.NewEnqueuer(pushStore, 30*time.Second, time.Now))
pushTransports := []push.Transport{
push.NewWebPushTransport(pushConfig.WebPush),
push.NewAPNsTransport(pushConfig.APNs),
push.NewFCMTransport(pushConfig.FCM),
}
pushWorker := push.NewWorker(push.NewStoreQueue(pushStore), presenceRegistry, pushTransports, time.Now)
deps.PushStore = pushStore
deps.PushConfig = pushConfig
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
if err != nil {
@@ -781,9 +707,6 @@ func main() {
ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)
s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan)
configWatcher.OnChange(func(_, updated *config.Config) {
s.SetWorkers(updated.Scanner.Workers)
})
deps.Scanner = s
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
slog.Info("scanner initialized")
@@ -1121,9 +1044,6 @@ func main() {
imageCacher := imagecache.New(deps.S3Public)
metadataService.SetImageCacher(imageCacher)
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
configWatcher.OnChange(func(_, updated *config.Config) {
metadataService.SetAutoCacheImages(updated.Metadata.CacheImages)
})
if deps.Scanner != nil {
deps.Scanner.SetImageCacher(imageCacher)
}
@@ -1141,10 +1061,6 @@ func main() {
}
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
mwForReload := matchWorker
configWatcher.OnChange(func(_, updated *config.Config) {
mwForReload.SetConcurrency(updated.Matcher.Workers, updated.Matcher.BatchSize)
})
matchWorker.SetRealtimeHub(deps.RealtimeHub)
if movieQueueRepo != nil {
matchWorker.SetMovieFileClaimer(movieQueueRepo)
@@ -1579,10 +1495,6 @@ func main() {
deps.UserCollectionSync = userSync
deps.UserCollectionScheduler = userCollectionScheduler
deps.MDBListClient = mdblist.NewClient(cfg.MDBListAPIKey, nil)
mdblistForReload := deps.MDBListClient
configWatcher.OnChange(func(_, updated *config.Config) {
mdblistForReload.SetAPIKey(updated.MDBListAPIKey)
})
}
}
@@ -1642,7 +1554,6 @@ func main() {
),
)
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
requestReconcileSvc.SetEventsHub(eventsHub)
api.AttachRequestRouter(requestReconcileSvc, pluginService)
if userStoreProvider != nil {
reconcileResolver := access.NewResolver(
@@ -1701,9 +1612,6 @@ func main() {
taskMgr.Register(pluginTask)
}
}
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationsStore, pushStore))
taskMgr.Register(tasks.NewNotificationsDigestTask(notificationsSvc))
taskMgr.Register(push.NewPushDeliveryTask(pushWorker))
taskMgr.Start(appCtx)
defer taskMgr.Stop()
@@ -1723,9 +1631,6 @@ func main() {
cfg.Auth.AccessTokenExpiry,
cfg.Auth.RefreshTokenExpiry,
)
configWatcher.OnChange(func(_, updated *config.Config) {
absJWTService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
})
absAuthSvc := auth.NewService(
auth.NewLocalProvider(absUserRepo, absSessionRepo),
absJWTService,
@@ -1990,7 +1895,6 @@ func main() {
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" {
compatDeps := jellycompat.Dependencies{
Config: cfg,
LiveConfig: configWatcher.Config,
DB: deps.DB,
SecretCipher: dataCipher,
ClientIPResolver: ipResolver,
@@ -2050,7 +1954,7 @@ func main() {
// Construct auth service for jellycompat login.
userRepo := auth.NewUserRepository(deps.DB)
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
compatDeps.APIKeyUserLoader = userRepo
compatDeps.UserLoader = userRepo
compatDeps.ScanQueue = deps.LibraryScanQueue
sessionRepo := auth.NewSessionRepository(deps.DB)
jwtService := auth.NewJWTService(
@@ -2058,9 +1962,6 @@ func main() {
cfg.Auth.AccessTokenExpiry,
cfg.Auth.RefreshTokenExpiry,
)
configWatcher.OnChange(func(_, updated *config.Config) {
jwtService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
})
provider := auth.NewLocalProvider(userRepo, sessionRepo)
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
@@ -1,100 +0,0 @@
# Notifications & Push — Test Checklist
A repeatable regression pass for the core notifications + push subsystems. Organized by layer: automated (fast, run anytime), server pipeline (API, no browser), real browser (human-in-the-loop), and not-yet-testable (future work). Run the automated layer on every change; run the browser layer before shipping a user-facing change.
Specs/plans this exercises live under `docs/superpowers/specs|plans/2026-06-10-core-notifications-*` and `*-push-*`.
---
## 1. Automated tests (fast, no live instance)
Run from the repo root. `export GOWORK=off` first (or run inside the worktree).
```bash
# Go — materializer, matchers, worker state machine, request events, presence, handlers, tasks
GOWORK=off go test ./internal/notifications/ ./internal/push/ ./internal/presence/ \
./internal/requests/ ./internal/api/handlers/ ./internal/taskmanager/tasks/
# Web — bell, inbox, settings, admin page, usePushDevice, SW logic
cd web && pnpm exec vitest run
```
Expected: all green. The Go suites cover the outcome state machine (sent/skipped/failed-with-backoff/dead + dead-token prune), child-suppression SQL, the `notifications.send` plugin contract, request lifecycle publishing, and presence refcounting. The web suites cover the UI and the `usePushDevice` lifecycle/race-guard.
What this does NOT cover: real WebSocket delivery, real push banners, real migrations against production-shaped data. Use layers 2–3 for those.
---
## 2. Server pipeline (API — proves source → materializer → enqueuer → worker, no browser)
Needs an admin API key and the instance reachable (on silo-new the API is `http://localhost:8090`; create a temp key, delete it after — see the snippet at the bottom). Observe results in the `notifications` and `push_deliveries` tables.
For each notification **source**, trigger it and confirm a row lands (and, if push is configured + a device registered, a `push_deliveries` row):
- [ ] **Announcement** — `POST /api/v1/admin/announcements {title, body, audience:{all:true}}` → confirm a `notifications` row (category `announcement`) for each targeted user. ⚠️ `audience:{all:true}` fans out to **every** user's inbox — use `{user_ids:[<your id>]}` when testing on a live instance.
- [ ] **Request lifecycle** — submit a media request, then `POST /api/v1/requests/{id}/approve` (admin) → confirm a `request.approved` notification for the requester. Decline/complete/fail similarly produce `request.declined` / `request.completed` / `request.failed`.
- [ ] **System** — admin user-update that changes a password → confirm a `system.password_changed` notification for that user.
- [ ] **Content** — add an item to a profile's watchlist/favorites, then add/scan a matching library item → confirm a `content.added` notification for that profile (respects the 48h recency gate + per-day burst dedup).
- [ ] **Admin alert** — force a failed job/scan → confirm an `admin`-category notification fans out to admin users only (throttled 1/hour per source).
- [ ] **Digest** — opt a user into `content_digest`, then run the `notifications_digest` task (or wait for 08:00) → one digest notification if there were additions.
- [ ] **Push test** — `POST /api/v1/admin/push/test` → 202; a `system.test_push` notification + a `push_deliveries` row per the caller's registered push devices; the worker transitions it within ~30–45s (grace + tick). With a real subscription it sends; with a bogus one it fails-with-backoff (still proves the worker path).
Push config endpoints to verify:
- [ ] `GET /api/v1/admin/push/status` → `{apns,fcm,webpush}` booleans (flip after saving creds).
- [ ] `POST /api/v1/admin/push/generate-vapid-keys` → `{vapid_public, vapid_private}` (admin only).
- [ ] `GET /api/v1/notifications/push/webpush-key` → the configured public key (empty when unconfigured).
- [ ] `PUT /api/v1/notifications/push/device {transport:"webpush", token:<subscription JSON>}` with `X-Profile-Id` → 204 (400 without the profile header — the route is profile-gated).
- [ ] `GET /api/v1/notifications/push/devices` → lists the user's push devices; `PUT .../devices/{id} {enabled}` toggles.
---
## 3. Real browser (the only way to validate the UI + actual push banner)
Open the web app over a secure context (`localhost` or HTTPS — service workers require it). On silo-new that's `http://localhost:8090` (tunnel if remote).
In-app notifications:
- [ ] **Bell + badge** — top of the app; unread count badge.
- [ ] **Dropdown** — latest notifications, "Mark all read", deep links.
- [ ] **Inbox page** (`/notifications`) — category tabs, dismiss, mark-on-view, "Load more".
- [ ] **Live toast** — with the app open, trigger a notification (announcement to yourself, or the push-test) → a toast appears in real time (via the WebSocket), unless that category is muted.
- [ ] **Preferences** — Settings → Notifications → per-category mute toggles persist; "Daily digest" defaults off; the admin category shows only for admins.
Push (the headline manual test):
- [ ] **Admin config** — Admin → Settings → Push Notifications → "Generate keys", save the VAPID public/private + a `mailto:` subject → the Web Push badge flips to "Ready".
- [ ] **Enable on this device** — Settings → Notifications → "Push on this device" → grant the browser permission prompt → a device row appears in the list.
- [ ] **Background banner** — **close the app tab**, then `POST /admin/push/test` (or trigger any notification) → within ~30–45s an OS notification banner appears.
- [ ] **Click-through** — click the banner → the app opens/focuses and navigates to the notification's deep link.
- [ ] **Presence gating** — with the app tab open and focused, the same notification should NOT push a redundant banner (you saw it in-app); after closing the tab for >30s it does.
- [ ] **Per-device toggle / revoke** — disable the device in the list → no more banners; re-enable restores.
---
## 4. Not yet testable (tracked future work)
- **APNs (Apple) / FCM (Android)** — no mobile clients exist to register tokens, so these transports are only unit-tested (pure result-mappers). End-to-end needs silo-apple / silo-android (Phase 3 mobile).
- **Plugin `notifications.send`** — the ebook/audiobook request plugins don't publish it yet. Until those one-line `PublishEvent("notifications.send", …)` calls are added, plugin-sourced notifications can't be exercised end to end (the contract itself is unit-tested).
- **iOS Safari web push** — only works for an installed PWA, not a normal tab; the client degrades to an "unsupported" state otherwise.
---
## Appendix — temp admin key on silo-new (create, use, delete)
```bash
# create
KEY="sa_$(openssl rand -hex 32)"
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"INSERT INTO api_keys (user_id,label,api_key) VALUES (1,'manual-test','$KEY');"
echo "$KEY" # use as: Authorization: Bearer $KEY against http://localhost:8090/api/v1/...
# observe
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"SELECT id,category,type,title FROM notifications ORDER BY id DESC LIMIT 10;"
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"SELECT status,transport,attempts,left(last_error,40) FROM push_deliveries ORDER BY id DESC LIMIT 10;"
# clean up (delete the key + any test artifacts you created)
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"DELETE FROM api_keys WHERE label='manual-test';"
```
Always remove test notifications/devices you create on a real user's account; `push_deliveries` cascades when its `notifications` row is deleted.
File diff suppressed because it is too large Load Diff
@@ -1,809 +0,0 @@
# Core Notifications — Web UI Implementation Plan (Phase 2 of 3)
> **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:** Notifications UI in the silo-server web app: unread badge (sidebar + mobile header bell with dropdown), live toasts from the events WebSocket, an inbox page, per-category preference toggles in settings, and an admin announcements page.
**Architecture:** A `notifications` query-hook module talks to the Phase-1 REST API; one live-wiring hook subscribes to the `notifications` WS channel via the existing `RealtimeEventsProvider` ref-counted `useEventChannel`, firing sonner toasts and patching React Query caches. UI components consume only the hooks. Desktop surface is an `AppSidebar` item with badge (no desktop top bar exists); mobile surface is a bell + popover in the `Layout` header.
**Tech Stack:** React 19, React Router v7, TanStack Query, sonner, lucide-react, shadcn/ui primitives (Popover, Switch, Dialog), Vitest + RTL.
**Spec:** `docs/superpowers/specs/2026-06-10-core-notifications-design.md` (Web UI section). Server API shipped in Phase 1.
Commands assume the repository root is the cwd; web commands run in `web/`. `pnpm` is the package manager.
---
## Server API contract (frozen, Phase 1)
The `api()` client prefixes `/api/v1` and adds auth + `X-Profile-Id` headers automatically.
```
GET /notifications?unread=1&category=&cursor=&limit= → {"items": Notification[], "next_cursor": number|null}
GET /notifications/unread-count → {"count": number}
POST /notifications/read {"ids":[...]} | {"all":true} → 204
POST /notifications/{id}/dismiss → 204 | 404
GET /notifications/preferences → {"preferences":[{"category","enabled"}]}
PUT /notifications/preferences {"preferences":[...]} → 204 | 400
GET /admin/announcements → {"items": Announcement[]}
POST /admin/announcements {title, body, audience:{all|user_ids|library_ids}, expires_at?} → 201
DELETE /admin/announcements/{id} → 204 | 404
```
Notification JSON: `{id, user_id, profile_id?, category, type, title, body, link?, item_id?, created_at, read_at?, expires_at?}`. Categories: `request | content | announcement | system | admin`; preference categories additionally `content_digest` (never a notification category). WS: channel `"notifications"`, event `"notification.created"`, `data` = the Notification; subscribe snapshot `data` = `{"unread_count": number}`.
## File map
| File | Action | Responsibility |
|---|---|---|
| `web/src/api/types.ts` | modify | `AppNotification`, `NotificationPreference`, `Announcement`, `AnnouncementAudience` types; add `"notifications"` to `EventChannel` |
| `web/src/hooks/queries/keys.ts` | modify | `notificationKeys` factory |
| `web/src/hooks/queries/notifications.ts` | create | all query/mutation hooks (user + admin) |
| `web/src/hooks/useNotificationsLive.ts` | create | WS subscription → toast + cache patching |
| `web/src/hooks/useNotificationsLive.test.ts` | create | live-hook tests |
| `web/src/components/NotificationBell.tsx` | create | badge + popover dropdown (mobile header) |
| `web/src/components/NotificationBell.test.tsx` | create | bell tests |
| `web/src/components/Layout.tsx` | modify | mount bell in mobile header; mount live hook |
| `web/src/components/AppSidebar.tsx` | modify | Notifications nav item with badge |
| `web/src/pages/Notifications.tsx` | create | inbox page |
| `web/src/pages/Notifications.test.tsx` | create | inbox tests |
| `web/src/pages/settings/NotificationsSettings.tsx` | create | preference toggles |
| `web/src/pages/SettingsLayout.tsx` | modify | nav section entry |
| `web/src/pages/AdminAnnouncements.tsx` | create | admin CRUD page |
| `web/src/components/AdminSidebar.tsx` | modify | announcements nav item |
| `web/src/App.tsx` | modify | routes: `/notifications`, admin `announcements`, settings `notifications` |
---
### Task 1: API types and event channel
**Files:**
- Modify: `web/src/api/types.ts` (EventChannel union ~line 2198; types appended near other domain types)
- [ ] **Step 1: Add the types**
Add `"notifications"` to the `EventChannel` union. Then add (named `AppNotification` to avoid colliding with the DOM `Notification` type — verify no existing `AppNotification` symbol first with grep):
```typescript
export type NotificationCategory = "request" | "content" | "announcement" | "system" | "admin";
export type NotificationPreferenceCategory = NotificationCategory | "content_digest";
export interface AppNotification {
id: number;
user_id: number;
profile_id?: string;
category: NotificationCategory;
type: string;
title: string;
body: string;
link?: string;
item_id?: string;
created_at: string;
read_at?: string;
expires_at?: string;
}
export interface NotificationListResponse {
items: AppNotification[] | null;
next_cursor: number | null;
}
export interface NotificationPreference {
category: NotificationPreferenceCategory;
enabled: boolean;
}
export interface AnnouncementAudience {
all?: boolean;
user_ids?: number[];
library_ids?: number[];
}
export interface Announcement {
id: number;
title: string;
body: string;
audience: AnnouncementAudience;
created_by?: number;
created_at: string;
expires_at?: string;
}
```
- [ ] **Step 2: Typecheck and commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
git add src/api/types.ts && git commit -m "feat(web): notification API types and event channel"
```
(If the repo's typecheck command differs — check package.json scripts for `typecheck`/`build` — use that; `pnpm run build` also typechecks via `tsc -b`.)
---
### Task 2: Query keys and hooks
**Files:**
- Modify: `web/src/hooks/queries/keys.ts`
- Create: `web/src/hooks/queries/notifications.ts`
- [ ] **Step 1: Keys** — follow the existing factory style in keys.ts:
```typescript
export const notificationKeys = {
all: ["notifications"] as const,
list: (filters: { unread?: boolean; category?: string }) =>
[...notificationKeys.all, "list", filters] as const,
unreadCount: () => [...notificationKeys.all, "unread-count"] as const,
preferences: () => [...notificationKeys.all, "preferences"] as const,
announcements: () => ["admin", "announcements"] as const,
};
```
- [ ] **Step 2: Hooks** — `web/src/hooks/queries/notifications.ts`. Mirror the conventions in `web/src/hooks/queries/admin/users.ts` (api client import path, toast usage, invalidation):
```typescript
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import type {
Announcement,
AnnouncementAudience,
AppNotification,
NotificationListResponse,
NotificationPreference,
} from "@/api/types";
import { notificationKeys } from "@/hooks/queries/keys";
const PAGE_SIZE = 25;
export function useNotificationsList(filters: { unread?: boolean; category?: string } = {}) {
return useInfiniteQuery({
queryKey: notificationKeys.list(filters),
queryFn: async ({ pageParam }) => {
const params = new URLSearchParams();
params.set("limit", String(PAGE_SIZE));
if (filters.unread) params.set("unread", "1");
if (filters.category) params.set("category", filters.category);
if (pageParam) params.set("cursor", String(pageParam));
return api<NotificationListResponse>(`/notifications?${params.toString()}`);
},
initialPageParam: 0,
getNextPageParam: (last) => last.next_cursor ?? undefined,
});
}
export function useUnreadCount() {
return useQuery({
queryKey: notificationKeys.unreadCount(),
queryFn: () => api<{ count: number }>("/notifications/unread-count"),
select: (d) => d.count,
});
}
export function useMarkRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: { ids?: number[]; all?: boolean }) =>
api("/notifications/read", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to mark read");
},
});
}
export function useDismissNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api(`/notifications/${id}/dismiss`, { method: "POST" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to dismiss");
},
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: notificationKeys.preferences(),
queryFn: () => api<{ preferences: NotificationPreference[] }>("/notifications/preferences"),
select: (d) => d.preferences,
});
}
export function useSetNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (preferences: NotificationPreference[]) =>
api("/notifications/preferences", { method: "PUT", body: JSON.stringify({ preferences }) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.preferences() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save preferences");
},
});
}
export function useAnnouncements() {
return useQuery({
queryKey: notificationKeys.announcements(),
queryFn: () => api<{ items: Announcement[] | null }>("/admin/announcements"),
select: (d) => d.items ?? [],
});
}
export function useCreateAnnouncement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: {
title: string;
body: string;
audience: AnnouncementAudience;
expires_at?: string;
}) => api<Announcement>("/admin/announcements", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => {
toast.success("Announcement published");
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to publish");
},
});
}
export function useDeleteAnnouncement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api(`/admin/announcements/${id}`, { method: "DELETE" }),
onSuccess: () => {
toast.success("Announcement deleted");
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete");
},
});
}
```
Check `api()`'s actual error behavior (does it throw on non-2xx with a message? read `web/src/api/client.ts:343-380`) and the import alias style (`@/` vs relative — match the queries directory's existing imports).
- [ ] **Step 3: Typecheck + commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
git add src/hooks/queries/ && git commit -m "feat(web): notification query hooks"
```
---
### Task 3: Live wiring hook (WS → toasts + cache)
**Files:**
- Create: `web/src/hooks/useNotificationsLive.ts`
- Test: `web/src/hooks/useNotificationsLive.test.ts`
- [ ] **Step 1: Write the failing test**
Mock `useEventChannel` (from `@/components/realtimeEventsContext`) to capture handlers, mock `sonner`'s toast, wrap in a QueryClientProvider. Follow the mocking style of `web/src/components/RealtimeEventsProvider.test.tsx` (vi.mock + vi.hoisted).
```typescript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { capturedHandlers, toastMock } = vi.hoisted(() => ({
capturedHandlers: {} as { onEvent?: (m: unknown) => void; onSnapshot?: (m: unknown) => void },
toastMock: vi.fn(),
}));
vi.mock("@/components/realtimeEventsContext", () => ({
useEventChannel: (_channel: string, handlers: typeof capturedHandlers) => {
capturedHandlers.onEvent = handlers?.onEvent;
capturedHandlers.onSnapshot = handlers?.onSnapshot;
},
}));
vi.mock("sonner", () => ({ toast: toastMock }));
const profileState = vi.hoisted(() => ({ profile: { id: "p-1", is_child: false } }));
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => profileState }));
import { useNotificationsLive } from "./useNotificationsLive";
import { notificationKeys } from "@/hooks/queries/keys";
function setup() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(notificationKeys.unreadCount(), { count: 1 });
renderHook(() => useNotificationsLive(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
return queryClient;
}
function frame(data: object) {
return { type: "event", channel: "notifications", event: "notification.created", data };
}
describe("useNotificationsLive", () => {
it("toasts and bumps unread count on notification.created", () => {
const qc = setup();
capturedHandlers.onEvent?.(
frame({ id: 5, category: "request", title: "Request approved", body: "Dune", profile_id: "p-1" }),
);
expect(toastMock).toHaveBeenCalledWith("Request approved", expect.anything());
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 2 });
});
it("ignores frames for another profile", () => {
setup();
capturedHandlers.onEvent?.(
frame({ id: 6, category: "content", title: "New content", body: "", profile_id: "p-other" }),
);
expect(toastMock).not.toHaveBeenCalled();
});
it("suppresses restricted categories on child profiles", () => {
profileState.profile = { id: "p-1", is_child: true };
setup();
capturedHandlers.onEvent?.(
frame({ id: 7, category: "request", title: "Request approved", body: "", profile_id: undefined }),
);
expect(toastMock).not.toHaveBeenCalled();
profileState.profile = { id: "p-1", is_child: false };
});
it("seeds unread count from snapshot", () => {
const qc = setup();
capturedHandlers.onSnapshot?.({ type: "snapshot", channel: "notifications", data: { unread_count: 9 } });
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 9 });
});
});
```
(The test file needs `.tsx` extension if JSX is used in the wrapper — name it `useNotificationsLive.test.tsx`.)
- [ ] **Step 2: Run to verify failure**
```bash
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
```
Expected: FAIL — module `./useNotificationsLive` not found.
- [ ] **Step 3: Implement**
```typescript
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { AppNotification } from "@/api/types";
import { useEventChannel } from "@/components/realtimeEventsContext";
import { notificationKeys } from "@/hooks/queries/keys";
import { useAuth } from "@/hooks/useAuth";
const CHILD_HIDDEN_CATEGORIES = new Set(["request", "system", "admin"]);
/**
* Subscribes to the notifications WS channel: fires a toast for live
* notifications addressed to the active profile, keeps the unread-count
* cache current, and seeds it from the subscribe snapshot.
* Mount exactly once inside the authenticated layout.
*/
export function useNotificationsLive() {
const queryClient = useQueryClient();
const { profile } = useAuth();
const profileId = profile?.id;
const isChild = profile?.is_child ?? false;
const onEvent = useCallback(
(message: unknown) => {
const frame = message as { event?: string; data?: AppNotification };
if (frame.event !== "notification.created" || !frame.data) return;
const n = frame.data;
if (n.profile_id && n.profile_id !== profileId) return;
if (isChild && CHILD_HIDDEN_CATEGORIES.has(n.category)) return;
queryClient.setQueryData<{ count: number }>(notificationKeys.unreadCount(), (prev) => ({
count: (prev?.count ?? 0) + 1,
}));
void queryClient.invalidateQueries({
queryKey: [...notificationKeys.all, "list"],
refetchType: "none",
});
toast(n.title, { description: n.body });
},
[queryClient, profileId, isChild],
);
const onSnapshot = useCallback(
(message: unknown) => {
const frame = message as { data?: { unread_count?: number } };
if (typeof frame.data?.unread_count !== "number") return;
queryClient.setQueryData(notificationKeys.unreadCount(), { count: frame.data.unread_count });
},
[queryClient],
);
useEventChannel("notifications", { onEvent, onSnapshot });
}
```
NOTE on handler identity: `useEventChannel`'s effect re-subscribes when the handlers object changes — pass a stable object. Check how existing consumers handle this (grep `useEventChannel(` usages); if they memoize the handlers object, do the same: `useMemo(() => ({ onEvent, onSnapshot }), [onEvent, onSnapshot])`.
The toast call shape (`toast(title, { description })`) must match the test's `expect.anything()` second arg. If `useAuth` returns a different profile field shape, adapt (read `useAuth.tsx`).
- [ ] **Step 4: Run tests until green**
```bash
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
```
- [ ] **Step 5: Commit**
```bash
git add web/src/hooks/useNotificationsLive.* && git commit -m "feat(web): live notifications wiring (toasts, unread cache)"
```
---
### Task 4: NotificationBell component + Layout mount
**Files:**
- Create: `web/src/components/NotificationBell.tsx`
- Test: `web/src/components/NotificationBell.test.tsx`
- Modify: `web/src/components/Layout.tsx` (mobile header actions ~lines 151-168; also mount `useNotificationsLive()` once in the Layout component body)
- [ ] **Step 1: Failing test**
```typescript
// Mocks: @/hooks/queries/notifications (useUnreadCount → 3, useNotificationsList → two items,
// useMarkRead → mutate spy), react-router (MemoryRouter wrapper).
// Asserts: badge renders "3"; opening the popover (click bell button, role="button" name /notifications/i)
// shows both item titles; "Mark all read" calls markRead with {all:true};
// item with link renders an anchor/Link to that link.
```
Write it concretely following an existing component test's structure (see `web/src/components/AppSidebar.test.tsx` for provider wrapping conventions). Badge cap: 4+ digits render "99+" when count > 99 — include one assertion with count 120.
- [ ] **Step 2: Implement NotificationBell**
```tsx
import { Bell } from "lucide-react";
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
useMarkRead,
useNotificationsList,
useUnreadCount,
} from "@/hooks/queries/notifications";
export function NotificationBell() {
const { data: count = 0 } = useUnreadCount();
const list = useNotificationsList({});
const markRead = useMarkRead();
const items = (list.data?.pages ?? []).flatMap((p) => p.items ?? []).slice(0, 10);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
<Bell className="h-5 w-5" />
{count > 0 && (
<Badge className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]">
{count > 99 ? "99+" : count}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="sm"
disabled={count === 0}
onClick={() => markRead.mutate({ all: true })}
>
Mark all read
</Button>
</div>
<ul className="max-h-96 list-none overflow-y-auto">
{items.length === 0 && (
<li className="text-muted-foreground px-3 py-6 text-center text-sm">
You're all caught up
</li>
)}
{items.map((n) => (
<li key={n.id} className={n.read_at ? "opacity-60" : ""}>
<Link to={n.link ?? "/notifications"} className="hover:bg-accent block px-3 py-2">
<div className="text-sm font-medium">{n.title}</div>
{n.body && <div className="text-muted-foreground truncate text-xs">{n.body}</div>}
</Link>
</li>
))}
</ul>
<div className="border-t px-3 py-2 text-center">
<Link to="/notifications" className="text-primary text-sm">
View all
</Link>
</div>
</PopoverContent>
</Popover>
);
}
```
Verify the ui primitives exist with these export names (`badge.tsx`, `button.tsx`, `popover.tsx` under `components/ui/`); adjust imports/props to the actual variants. Match `react-router` import name used elsewhere (the repo uses `react-router` v7 — check whether components import `Link` from `react-router` or `react-router-dom`).
Dropdown-open toast suppression (spec requirement): export a tiny module-level flag from NotificationBell —
```tsx
let bellOpenFlag = false;
export function isNotificationDropdownOpen() {
return bellOpenFlag;
}
// in the component: <Popover onOpenChange={(open) => { bellOpenFlag = open; }}>
```
and in `useNotificationsLive`'s onEvent, before toasting: `if (isNotificationDropdownOpen()) return;` (cache updates still run — only the toast is suppressed; the open dropdown shows the new item via the list invalidation). Add one live-hook test: set the flag via a popover-open simulation or export a test-only setter, assert no toast while open.
- [ ] **Step 3: Mount in Layout**
In `Layout.tsx`: call `useNotificationsLive()` at the top of the component (single mount point for the live hook), and render `<NotificationBell />` in the mobile header's right-side action cluster (next to the search icon button).
- [ ] **Step 4: Tests green, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/components/NotificationBell.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/components/NotificationBell.* src/components/Layout.tsx src/hooks/
git commit -m "feat(web): notification bell with live badge in mobile header"
```
---
### Task 5: Sidebar nav item with badge (desktop)
**Files:**
- Modify: `web/src/components/AppSidebar.tsx` (nav list — insert after the History item, ~line 645)
- Test: extend `web/src/components/AppSidebar.test.tsx`
- [ ] **Step 1: Add the nav item**
Follow the exact ViewTransitionLink pattern of the History item (active indicator span, icon sizing, `SidebarLabel`):
```tsx
<li>
<ViewTransitionLink
to="/notifications"
onClick={onNavigate}
className={navLinkClass("/notifications")}
aria-current={isActive("/notifications") ? "page" : undefined}
>
{isActive("/notifications") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Bell className="h-[18px] w-[18px] shrink-0" />
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground absolute top-1 left-6 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px]">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
<SidebarLabel show={showLabels}>Notifications</SidebarLabel>
</ViewTransitionLink>
</li>
```
`const { data: unreadCount = 0 } = useUnreadCount();` at the top of AppSidebar (import from `@/hooks/queries/notifications`; `Bell` from lucide-react — add to the existing lucide import list at line ~56). Badge positioning: verify visually against the collapsed-sidebar state (`showLabels` false) — the absolute chip must remain visible on the icon in both states; tweak offsets if the icon container differs.
- [ ] **Step 2: Test**
Extend AppSidebar.test.tsx: mock `@/hooks/queries/notifications` `useUnreadCount` to return `{ data: 5 }`; assert the rendered sidebar contains a link to `/notifications` showing "5". Follow the file's existing mock arrangement for other hooks.
- [ ] **Step 3: Run, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/components/AppSidebar.test.tsx 2>&1 | tail -4
git add src/components/AppSidebar.* && git commit -m "feat(web): notifications sidebar item with unread badge"
```
---
### Task 6: Inbox page + route
**Files:**
- Create: `web/src/pages/Notifications.tsx`
- Test: `web/src/pages/Notifications.test.tsx`
- Modify: `web/src/App.tsx` (user routes block ~line 331+)
- [ ] **Step 1: Failing test**
Mock the notifications query hooks. Assertions:
- renders rows for items across categories with title/body/relative time;
- category filter tabs (All, Requests, Content, Announcements, System — admin tab only when `useAuth().user?.role === "admin"`) change the `category` filter passed to `useNotificationsList` (capture via mock);
- unread rows visually distinct (e.g. a `data-unread="true"` attribute — assert on it);
- per-row dismiss button calls `useDismissNotification().mutate(id)`;
- "Load more" appears when `hasNextPage` and calls `fetchNextPage`;
- on mount, visible unread ids are marked read: `useMarkRead().mutate({ ids: [...] })` (assert called once with the unread ids from page one).
- [ ] **Step 2: Implement**
Page structure (use the repo's page conventions — check an existing simple page like the History/Collections page for header/container classes):
```tsx
const CATEGORY_TABS: { key: string; label: string; adminOnly?: boolean }[] = [
{ key: "", label: "All" },
{ key: "request", label: "Requests" },
{ key: "content", label: "Content" },
{ key: "announcement", label: "Announcements" },
{ key: "system", label: "System" },
{ key: "admin", label: "Admin", adminOnly: true },
];
```
- `useState` for active category; `useNotificationsList({ category })` (omit when "");
- mark-on-view effect:
```tsx
const firstPage = list.data?.pages?.[0];
const markRead = useMarkRead();
const markedRef = useRef(false);
useEffect(() => {
if (markedRef.current || !firstPage) return;
const unreadIds = (firstPage.items ?? []).filter((n) => !n.read_at).map((n) => n.id);
if (unreadIds.length > 0) markRead.mutate({ ids: unreadIds });
markedRef.current = true;
}, [firstPage, markRead]);
```
- rows: title, body, `created_at` rendered with the repo's existing relative-time helper (grep `formatDistance\|relativeTime\|timeAgo` in web/src/lib — use what exists; if nothing, use `Intl.RelativeTimeFormat` inline helper in the page);
- dismiss button (X icon) per row; "Mark all read" header button; "Load more" via `fetchNextPage` when `hasNextPage`;
- empty state per tab.
- [ ] **Step 3: Route**
In `App.tsx`, register alongside the other authenticated user pages (match the guard used by e.g. the favorites/history page — likely inside the layout route with profile required):
```tsx
<Route path="/notifications" element={<Notifications />} />
```
(Lazy-load if neighboring pages are lazy — match the file's import pattern.)
- [ ] **Step 4: Tests, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/pages/Notifications.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/pages/Notifications.* src/App.tsx && git commit -m "feat(web): notifications inbox page"
```
---
### Task 7: Settings preferences section
**Files:**
- Create: `web/src/pages/settings/NotificationsSettings.tsx`
- Modify: `web/src/pages/SettingsLayout.tsx` (NAV_SECTIONS), `web/src/App.tsx` (settings child route)
- [ ] **Step 1: Implement the settings page**
Template: `web/src/pages/settings/PlaybackSettings.tsx` (SettingsGroup/SettingRow/Switch composition). Content:
```tsx
const PREF_LABELS: Record<string, { label: string; description: string; adminOnly?: boolean }> = {
request: { label: "Requests", description: "Updates when your requests are approved, declined, or ready" },
content: { label: "New content", description: "New arrivals matching your watchlist, favorites, and shows in progress" },
system: { label: "Account & security", description: "Password changes and account notices" },
admin: { label: "Admin alerts", description: "Failed jobs and scans (admins only)", adminOnly: true },
content_digest: { label: "Daily digest", description: "One daily summary of everything added to your libraries" },
};
```
- `useNotificationPreferences()` for current values; render a `Switch` per category in PREF_LABELS order, skipping `adminOnly` entries when `useAuth().user?.role !== "admin"`;
- on toggle: `useSetNotificationPreferences().mutate([{ category, enabled }])` (single-category PUT — server upserts per row);
- note under the group: "Announcements from your server admin can't be turned off."
- [ ] **Step 2: Register in SettingsLayout NAV_SECTIONS**
Add a "Notifications" section (icon `Bell`) pointing at path `notifications`, following the structure at SettingsLayout.tsx lines 39-100. Register the child route in App.tsx where other settings pages are routed (find `PlaybackSettings` route registration and mirror it).
- [ ] **Step 3: Test**
Extend `web/src/pages/SettingsLayout.test.tsx` if it asserts nav items (check); add `NotificationsSettings` rendering test only if sibling settings pages have tests (check `ls web/src/pages/settings/*.test.tsx`) — match local convention; if none exist, rely on the layout test + typecheck (note this in the commit message).
- [ ] **Step 4: Typecheck, test, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/SettingsLayout.test.tsx 2>&1 | tail -4
git add src/pages/settings/NotificationsSettings.tsx src/pages/SettingsLayout.tsx src/App.tsx
git commit -m "feat(web): notification preference toggles in settings"
```
---
### Task 8: Admin announcements page
**Files:**
- Create: `web/src/pages/AdminAnnouncements.tsx`
- Modify: `web/src/components/AdminSidebar.tsx` (nav items ~lines 81-134), `web/src/App.tsx` (admin routes ~lines 363-398)
- [ ] **Step 1: Implement the page**
Template: `web/src/pages/AdminUsers.tsx` for the table + dialog patterns (read it first; reuse its Table/Dialog/Button composition). Features:
- Table of announcements: title, audience summary ("Everyone" / "N users" / "Libraries: a, b"), created date, expires date, delete button with confirm dialog ("Deleting dismisses unread copies from user inboxes.").
- "New announcement" button → Dialog with: title (Input, required), body (Textarea), audience radio group (Everyone / Specific users / Libraries) — for "Specific users" a multi-select of users (check how AdminUsers fetches the user list — reuse that hook; if a multi-select primitive is missing, comma-separated ids input is NOT acceptable — use checkboxes in a scrollable list), for Libraries the same against the libraries list hook (grep `useLibraries` in hooks/queries) — optional `expires_at` datetime-local input.
- Submit via `useCreateAnnouncement()`; disable submit until title non-empty and a valid audience selection exists.
- [ ] **Step 2: Nav + route**
AdminSidebar: add item (icon `Megaphone` from lucide-react) labeled "Announcements" → `/admin/announcements`, following the existing items' structure. App.tsx admin routes: `<Route path="announcements" element={<AdminAnnouncements />} />`.
- [ ] **Step 3: Test**
`web/src/pages/AdminAnnouncements.test.tsx` — mock the three admin hooks; assert: table renders rows incl. audience summaries; create dialog validates (submit disabled with empty title); submitting with Everyone calls `useCreateAnnouncement().mutate` with `{audience: {all: true}, ...}`; delete confirm calls `useDeleteAnnouncement().mutate(id)`.
- [ ] **Step 4: Tests, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/pages/AdminAnnouncements.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/pages/AdminAnnouncements.* src/components/AdminSidebar.tsx src/App.tsx
git commit -m "feat(web): admin announcements management page"
```
---
### Task 9: Full verification
- [ ] **Step 1: Web checks**
```bash
cd web && pnpm run lint && pnpm run format:check && pnpm exec vitest run 2>&1 | tail -6 && pnpm run build 2>&1 | tail -4
```
All green; build also refreshes `web/dist` so the Go embed keeps working.
- [ ] **Step 2: Backend still green**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ 2>&1 | tail -2
make verify-local-paths
```
- [ ] **Step 3: E2E smoke against the dev stack**
```bash
docker compose up -d postgres redis
GOWORK=off go build -o /tmp/silo-web-e2e ./cmd/silo && /tmp/silo-web-e2e &
```
In a browser (or report for manual check): log in, confirm the sidebar Notifications item + mobile bell render; create an announcement in /admin/announcements; verify toast fires live (WS) and badge increments without reload; open inbox, rows mark read, badge clears; toggle a preference off and verify it persists after reload.
- [ ] **Step 4: Commit anything outstanding; report**
---
## Self-review notes
- Desktop bell+dropdown from the spec adapted to a sidebar nav item + badge (no desktop top bar exists in this app); the dropdown ships on the mobile header bell. Spec intent (always-visible unread indicator + 1-click access) preserved.
- Toast child/profile filtering is client-side by necessity (WS frames are user-scoped; see spec Future work on claims enrichment) — `useNotificationsLive` suppresses other-profile and child-restricted frames.
- `content_digest` rows arrive as category `content` (server stores digest as content/content.digest) — the inbox Content tab includes them; the preferences page exposes the separate `content_digest` toggle. No UI filters by `content_digest` category (final server review noted this trap).
- Mark-on-view marks only page one's unread ids once per mount — deliberate; deeper pages mark via "Mark all read".
- Several steps include verify-greps (ui primitive names, Link import source, relative-time helper, settings test conventions, multi-select availability) — implementers must align with what exists rather than invent.
File diff suppressed because it is too large Load Diff
@@ -1,838 +0,0 @@
# Core Push — Web Client & Admin Config — 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:** Make push operable end-to-end from the browser: a Web Push client (service worker, subscribe, receive, per-device control, device list) plus an admin provider-config page (APNs/FCM/WebPush credentials, VAPID keygen, readiness), on top of the frozen push API.
**Architecture:** Five web units + two small Go endpoints. A static service worker receives pushes; a `usePushDevice` hook owns the browser subscription lifecycle; the existing notifications-settings page gains a push control + device list; a new admin page reuses the existing encrypted-settings write path for credentials. Two net-new server endpoints: VAPID keygen and a self-targeted test push.
**Tech Stack:** React 19, TanStack Query, sonner, the existing api client (auto device/profile headers), browser Push API / Service Worker / Notifications API, Go (chi), webpush-go (already a dep). Vitest + RTL.
**Spec:** `docs/superpowers/specs/2026-06-10-core-push-web-client-design.md`. The push registration API + delivery worker are frozen (Phase-3-server).
Commands assume the repository root is the cwd; web commands run in `web/` with `pnpm`. Go: `export GOWORK=off`.
## Frozen API the client calls
```
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
PUT /api/v1/notifications/push/device {transport, token} → 204 (needs X-Profile-Id; api client sends it)
DELETE /api/v1/notifications/push/device → 204
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,transport,push_enabled,registered_at}]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
PUT /api/v1/admin/settings/{key} {value} → writes an encrypted push.* key (existing)
GET /api/v1/admin/settings/sensitive-status → {configured:[keys]} (existing)
```
Net-new this plan: `POST /api/v1/admin/push/generate-vapid-keys` and `POST /api/v1/admin/push/test`.
## File map
| File | Action | Responsibility |
|---|---|---|
| `internal/api/handlers/push.go` | modify | add `HandleGenerateVAPIDKeys`, `HandleSendTestPush`; add a `notifier` dep |
| `internal/api/router.go` | modify | register the two admin routes |
| `cmd/silo/main.go` | modify | pass the notifications service into the push handler |
| `web/public/sw.js` | create | service worker: push, notificationclick, pushsubscriptionchange |
| `web/src/lib/pushSw.ts` | create | pure SW logic (notification options, click URL) — tested; mirrored in sw.js |
| `web/src/lib/push.ts` | create | `urlBase64ToUint8Array`, VAPID-key cache helpers |
| `web/src/main.tsx` | modify | register `/sw.js` when supported |
| `web/src/api/types.ts` | modify | `PushDeviceInfo`, `VapidKeyPair` |
| `web/src/hooks/queries/push.ts` | create | query/mutation hooks (webpush-key, devices, toggle, register/revoke, admin keygen/test) |
| `web/src/hooks/usePushDevice.ts` | create | browser subscription lifecycle state machine |
| `web/src/hooks/usePushDevice.test.tsx` | create | hook tests |
| `web/src/pages/settings/NotificationsSettings.tsx` | modify | push control + device list |
| `web/src/pages/admin-settings/PushSettings.tsx` | create | three credential cards + keygen + test |
| `web/src/pages/admin-settings/AdminSettingsLayout.tsx` | modify | nav entry |
---
### Task 1: Server endpoints — VAPID keygen + test push
**Files:**
- Modify: `internal/api/handlers/push.go`, `internal/api/router.go`, `cmd/silo/main.go`
- Test: `internal/api/handlers/push_test.go`
- [ ] **Step 1: Add a notifier dep + the two handlers**
In `push.go`, add a narrow interface and a field on `PushHandler`:
```go
// pushNotifier creates a notification for the calling user (test push).
type pushNotifier interface {
CreateSystem(ctx context.Context, userID int, typ, title, body string)
}
```
Add `notifier pushNotifier` to `PushHandler` and a parameter to `NewPushHandler` (nil-tolerant — test-push 503s if absent). `*notifications.Service` satisfies `pushNotifier` (it has `CreateSystem(ctx, userID int, typ, title, body string)` from Phase-1).
Handlers:
```go
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
return
}
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
}
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
if h.notifier == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
return
}
h.notifier.CreateSystem(r.Context(), userID,
"system.test_push", "Test push",
"If you can see this on a device, push delivery is working.")
w.WriteHeader(http.StatusAccepted)
}
```
Import `webpush "github.com/SherClockHolmes/webpush-go"`. **Verify the `GenerateVAPIDKeys()` return order** against the installed version — webpush-go returns `(privateKey, publicKey string, err error)`. Confirm by reading the vendored signature or `go doc`; if it's `(public, private, err)` swap accordingly. Get this right — swapping the keys silently breaks push.
- [ ] **Step 2: Routes**
In `router.go`, in the admin push block (next to `r.Get("/push/status", ...)`, ~line 2295, behind `deps.PushStore != nil`):
```go
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
r.Post("/push/test", pushHandler.HandleSendTestPush)
```
- [ ] **Step 3: Wire the notifier**
Update every `NewPushHandler(...)` call site (find with grep) to pass the notifications service. In `cmd/silo/main.go` and/or `router.go` where the push handler is constructed, add `deps.NotificationsService` (or the in-scope `notificationsSvc`) as the third arg. Build will tell you the sites.
- [ ] **Step 4: Tests**
Add to `push_test.go` (reuse the package's fakes / chi-request helper from Task-13 push tests):
```go
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rr := httptest.NewRecorder()
h.HandleGenerateVAPIDKeys(rr, httptest.NewRequest(http.MethodPost, "/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("code = %d", rr.Code)
}
var body map[string]string
_ = json.Unmarshal(rr.Body.Bytes(), &body)
if body["vapid_public"] == "" || body["vapid_private"] == "" || body["vapid_public"] == body["vapid_private"] {
t.Fatalf("bad key pair: %+v", body)
}
}
type fakeNotifier struct{ called bool; userID int; typ string }
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, _, _ string) {
f.called = true
f.userID = userID
f.typ = typ
}
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
n := &fakeNotifier{}
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
r := httptest.NewRequest(http.MethodPost, "/", nil)
r = r.WithContext(apimw.SetUserID(r.Context(), 7)) // use the package's claims-injection helper if SetUserID differs
rr := httptest.NewRecorder()
h.HandleSendTestPush(rr, r)
if rr.Code != http.StatusAccepted || !n.called || n.userID != 7 || n.typ != "system.test_push" {
t.Fatalf("unexpected: code=%d notifier=%+v", rr.Code, n)
}
}
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rr := httptest.NewRecorder()
h.HandleSendTestPush(rr, httptest.NewRequest(http.MethodPost, "/", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d", rr.Code)
}
}
```
Check how the existing push tests inject the user id into context (Task-13 used a claims/`apimw` helper — mirror it; `apimw.SetUserID` may not exist, use whatever the package's tests already use to set `GetUserID`).
- [ ] **Step 5: Build, test, commit**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush -v 2>&1 | tail -8 && GOWORK=off go vet ./internal/api/handlers/
git add internal/api/handlers/push.go internal/api/handlers/push_test.go internal/api/router.go cmd/silo/main.go
git commit -m "feat(push): admin VAPID keygen and self-test endpoints"
```
---
### Task 2: Service worker + registration + helpers
**Files:**
- Create: `web/public/sw.js`, `web/src/lib/pushSw.ts`, `web/src/lib/push.ts`
- Test: `web/src/lib/pushSw.test.ts`, `web/src/lib/push.test.ts`
- Modify: `web/src/main.tsx`
- [ ] **Step 1: Pure SW logic (tested) — `web/src/lib/pushSw.ts`**
```ts
export interface PushPayload {
id?: number;
title?: string;
body?: string;
link?: string;
category?: string;
}
export interface SwNotification {
title: string;
options: { body: string; data: { link: string }; tag: string };
}
/** Maps a raw push payload to showNotification args; tolerant of partial/missing fields. */
export function buildNotification(payload: PushPayload | null): SwNotification {
const p = payload ?? {};
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
const tag = p.id != null ? `n${p.id}` : "n";
return {
title: p.title && p.title.length > 0 ? p.title : "Silo",
options: { body: p.body ?? "", data: { link }, tag },
};
}
/** The URL a notification click should open/focus. */
export function clickUrl(data: unknown): string {
const d = (data ?? {}) as { link?: string };
return d.link && d.link.length > 0 ? d.link : "/notifications";
}
```
- [ ] **Step 2: Test it — `web/src/lib/pushSw.test.ts`**
```ts
import { describe, expect, it } from "vitest";
import { buildNotification, clickUrl } from "./pushSw";
describe("buildNotification", () => {
it("maps a full payload", () => {
const n = buildNotification({ id: 5, title: "Approved", body: "Dune", link: "/requests" });
expect(n.title).toBe("Approved");
expect(n.options).toEqual({ body: "Dune", data: { link: "/requests" }, tag: "n5" });
});
it("falls back on missing fields", () => {
const n = buildNotification(null);
expect(n.title).toBe("Silo");
expect(n.options.body).toBe("");
expect(n.options.data.link).toBe("/notifications");
expect(n.options.tag).toBe("n");
});
});
describe("clickUrl", () => {
it("uses link when present", () => expect(clickUrl({ link: "/items/9" })).toBe("/items/9"));
it("falls back to inbox", () => expect(clickUrl({})).toBe("/notifications"));
});
```
Run: `pnpm exec vitest run src/lib/pushSw.test.ts` → green.
- [ ] **Step 3: The static service worker — `web/public/sw.js`**
Self-contained (served statically, not bundled). Mirror the pure logic exactly (the canonical, tested source is `pushSw.ts`):
```js
// web/public/sw.js — static service worker for Web Push.
// NOTE: the buildNotification/clickUrl logic mirrors src/lib/pushSw.ts (the
// tested source of truth). Keep them in sync.
function buildNotification(payload) {
const p = payload || {};
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
const tag = p.id != null ? "n" + p.id : "n";
return {
title: p.title && p.title.length > 0 ? p.title : "Silo",
options: { body: p.body || "", data: { link: link }, tag: tag },
};
}
function clickUrl(data) {
const d = data || {};
return d.link && d.link.length > 0 ? d.link : "/notifications";
}
self.addEventListener("push", (event) => {
let payload = null;
try {
payload = event.data ? event.data.json() : null;
} catch (e) {
payload = null;
}
const n = buildNotification(payload);
event.waitUntil(self.registration.showNotification(n.title, n.options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = clickUrl(event.notification.data);
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => {
for (const c of wins) {
if ("focus" in c) {
c.postMessage({ type: "notification-click", link: url });
return c.focus();
}
}
return self.clients.openWindow(url);
}),
);
});
self.addEventListener("pushsubscriptionchange", (event) => {
// Best-effort re-subscribe using the cached VAPID key; the app re-registers
// the new subscription on next load if this fails.
event.waitUntil(
(async () => {
try {
const cache = await caches.open("silo-push");
const res = await cache.match("vapid-public-key");
if (!res) return;
const vapid = await res.text();
const sub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapid,
});
await fetch("/api/v1/notifications/push/device", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
});
} catch (e) {
// swallow; app re-registers on next load
}
})(),
);
});
```
Note: the `pushsubscriptionchange` re-register relies on cookie auth (`credentials: include`) and the device/profile headers it can't set from the SW — it is genuinely best-effort and the app heals it on next load (documented in the spec). `applicationServerKey` accepts a base64url string in modern browsers; if the target browsers need a Uint8Array here, the app-side path (Task 4) converts it — the SW path is the fallback and may no-op on stricter browsers, which is acceptable.
- [ ] **Step 4: App-side helpers — `web/src/lib/push.ts`**
```ts
/** Convert a base64url VAPID public key to the Uint8Array applicationServerKey wants. */
export function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(b64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
/** Cache the raw VAPID public key so the SW can re-subscribe without the app. */
export async function cacheVapidKey(base64: string): Promise<void> {
if (!("caches" in self)) return;
const cache = await caches.open("silo-push");
await cache.put("vapid-public-key", new Response(base64));
}
export function pushSupported(): boolean {
return (
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
typeof window !== "undefined" &&
"PushManager" in window &&
"Notification" in window
);
}
```
- [ ] **Step 5: Test the converter — `web/src/lib/push.test.ts`**
```ts
import { describe, expect, it } from "vitest";
import { urlBase64ToUint8Array } from "./push";
describe("urlBase64ToUint8Array", () => {
it("decodes a url-safe base64 key to bytes", () => {
// "AQID" → [1,2,3]
expect(Array.from(urlBase64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
});
it("handles url-safe chars and missing padding", () => {
const out = urlBase64ToUint8Array("a-_9"); // '-'→'+', '_'→'/'
expect(out.length).toBe(3);
});
});
```
- [ ] **Step 6: Register the SW — `web/src/main.tsx`**
After `createRoot(...).render(...)`, add:
```tsx
if ("serviceWorker" in navigator && "PushManager" in window) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {
// registration failure is non-fatal; push just won't be available
});
});
}
```
- [ ] **Step 7: Run, build, commit**
```bash
cd web && pnpm exec vitest run src/lib/pushSw.test.ts src/lib/push.test.ts 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/public/sw.js web/src/lib/pushSw.ts web/src/lib/pushSw.test.ts web/src/lib/push.ts web/src/lib/push.test.ts web/src/main.tsx
git commit -m "feat(web): web push service worker, registration, and helpers"
```
---
### Task 3: Push query hooks + types
**Files:**
- Modify: `web/src/api/types.ts`
- Create: `web/src/hooks/queries/push.ts`
- Modify: `web/src/hooks/queries/keys.ts` (add `pushKeys`)
- [ ] **Step 1: Types** — append to `types.ts`:
```ts
export interface PushDeviceInfo {
device_id: string;
name: string;
platform: string;
transport: string;
push_enabled: boolean;
registered_at?: string;
}
export interface VapidKeyPair {
vapid_public: string;
vapid_private: string;
}
```
- [ ] **Step 2: Keys** — in `keys.ts`:
```ts
export const pushKeys = {
all: ["push"] as const,
webpushKey: () => [...pushKeys.all, "webpush-key"] as const,
devices: () => [...pushKeys.all, "devices"] as const,
status: () => [...pushKeys.all, "status"] as const,
};
```
- [ ] **Step 3: Hooks — `web/src/hooks/queries/push.ts`**
Mirror `admin/users.ts` conventions (api import, toast, invalidation).
```ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import type { PushDeviceInfo, VapidKeyPair } from "@/api/types";
import { pushKeys } from "@/hooks/queries/keys";
export function useWebPushPublicKey() {
return useQuery({
queryKey: pushKeys.webpushKey(),
queryFn: () => api<{ vapid_public_key: string }>("/notifications/push/webpush-key"),
select: (d) => d.vapid_public_key,
staleTime: 5 * 60 * 1000,
});
}
export function usePushDevices() {
return useQuery({
queryKey: pushKeys.devices(),
queryFn: () => api<{ devices: PushDeviceInfo[] | null }>("/notifications/push/devices"),
select: (d) => d.devices ?? [],
});
}
export function useTogglePushDevice() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ deviceId, enabled }: { deviceId: string; enabled: boolean }) =>
api(`/notifications/push/devices/${deviceId}`, {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
onSuccess: () => void qc.invalidateQueries({ queryKey: pushKeys.devices() }),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to update device"),
});
}
export function usePushStatus() {
return useQuery({
queryKey: pushKeys.status(),
queryFn: () => api<{ apns: boolean; fcm: boolean; webpush: boolean }>("/admin/push/status"),
});
}
export function useGenerateVapidKeys() {
return useMutation({
mutationFn: () => api<VapidKeyPair>("/admin/push/generate-vapid-keys", { method: "POST" }),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to generate keys"),
});
}
export function useSendTestPush() {
return useMutation({
mutationFn: () => api("/admin/push/test", { method: "POST" }),
onSuccess: () => toast.success("Test push queued — watch for a banner"),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to send test"),
});
}
```
(Register/revoke device calls live inside `usePushDevice` (Task 4) since they're tied to the subscription, not standalone query hooks.)
- [ ] **Step 4: Typecheck + commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/src/api/types.ts web/src/hooks/queries/keys.ts web/src/hooks/queries/push.ts
git commit -m "feat(web): push query hooks and types"
```
---
### Task 4: `usePushDevice` lifecycle hook
**Files:**
- Create: `web/src/hooks/usePushDevice.ts`, `web/src/hooks/usePushDevice.test.tsx`
- [ ] **Step 1: Failing tests**
Mock `navigator.serviceWorker`, `window.PushManager`, `Notification`, the api client, and the push helpers. Assert the status machine and the enable/disable flows.
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { apiMock, cacheVapidMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
cacheVapidMock: vi.fn(),
}));
vi.mock("@/api/client", () => ({ api: apiMock }));
vi.mock("@/lib/push", async (orig) => ({
...(await orig<typeof import("@/lib/push")>()),
cacheVapidKey: cacheVapidMock,
}));
import { usePushDevice } from "./usePushDevice";
function wrap() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
let subscribeMock: ReturnType<typeof vi.fn>;
let getSubMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
apiMock.mockReset();
cacheVapidMock.mockReset();
subscribeMock = vi.fn(async () => ({ toJSON: () => ({ endpoint: "https://x", keys: {} }) }));
getSubMock = vi.fn(async () => null);
(globalThis as any).PushManager = function () {};
(navigator as any).serviceWorker = {
register: vi.fn(),
ready: Promise.resolve({ pushManager: { subscribe: subscribeMock, getSubscription: getSubMock } }),
};
(globalThis as any).Notification = { permission: "default", requestPermission: vi.fn(async () => "granted") };
});
afterEach(() => vi.restoreAllMocks());
describe("usePushDevice", () => {
it("reports unsupported when PushManager missing", () => {
delete (globalThis as any).PushManager;
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
expect(result.current.status).toBe("unsupported");
});
it("reports blocked when permission denied", () => {
(globalThis as any).Notification.permission = "denied";
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
expect(result.current.status).toBe("blocked");
});
it("enable(): permission → key → subscribe → PUT device, caches key, status on", async () => {
apiMock.mockImplementation((path: string) => {
if (path.includes("webpush-key")) return Promise.resolve({ vapid_public_key: "AQID" });
return Promise.resolve(undefined); // PUT device
});
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.enable(); });
expect(subscribeMock).toHaveBeenCalledOnce();
expect(cacheVapidMock).toHaveBeenCalledWith("AQID");
const putCall = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device");
expect(putCall?.[1]).toMatchObject({ method: "PUT" });
expect(JSON.parse(putCall![1].body)).toMatchObject({ transport: "webpush" });
await waitFor(() => expect(result.current.status).toBe("on"));
});
it("enable(): permission denied short-circuits, no subscribe", async () => {
(globalThis as any).Notification.requestPermission = vi.fn(async () => "denied");
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.enable(); });
expect(subscribeMock).not.toHaveBeenCalled();
expect(result.current.status).toBe("blocked");
});
it("disable(): unsubscribe + DELETE, status off", async () => {
const unsub = vi.fn(async () => true);
getSubMock.mockResolvedValue({ unsubscribe: unsub });
apiMock.mockResolvedValue(undefined);
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.disable(); });
expect(unsub).toHaveBeenCalled();
const del = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device" && c[1]?.method === "DELETE");
expect(del).toBeTruthy();
await waitFor(() => expect(result.current.status).toBe("off"));
});
});
```
- [ ] **Step 2: Run, verify fail**
```bash
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6
```
- [ ] **Step 3: Implement — `web/src/hooks/usePushDevice.ts`**
```ts
import { useCallback, useEffect, useState } from "react";
import { api } from "@/api/client";
import { cacheVapidKey, pushSupported, urlBase64ToUint8Array } from "@/lib/push";
export type PushDeviceStatus = "unsupported" | "blocked" | "off" | "on" | "pending";
export function usePushDevice() {
const [status, setStatus] = useState<PushDeviceStatus>("pending");
const refresh = useCallback(async () => {
if (!pushSupported()) {
setStatus("unsupported");
return;
}
if (Notification.permission === "denied") {
setStatus("blocked");
return;
}
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
setStatus(sub ? "on" : "off");
} catch {
setStatus("off");
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const enable = useCallback(async () => {
if (!pushSupported()) {
setStatus("unsupported");
return;
}
setStatus("pending");
const perm = await Notification.requestPermission();
if (perm !== "granted") {
setStatus(perm === "denied" ? "blocked" : "off");
return;
}
try {
const reg = await navigator.serviceWorker.ready;
const { vapid_public_key } = await api<{ vapid_public_key: string }>(
"/notifications/push/webpush-key",
);
if (!vapid_public_key) {
setStatus("off");
return;
}
await cacheVapidKey(vapid_public_key);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapid_public_key),
});
await api("/notifications/push/device", {
method: "PUT",
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
});
setStatus("on");
} catch {
setStatus("off");
}
}, []);
const disable = useCallback(async () => {
setStatus("pending");
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) await sub.unsubscribe();
} catch {
/* best-effort */
}
try {
await api("/notifications/push/device", { method: "DELETE" });
} catch {
/* server may already lack the row */
}
setStatus("off");
}, []);
return { status, enable, disable, refresh };
}
```
- [ ] **Step 4: Run green, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/src/hooks/usePushDevice.ts web/src/hooks/usePushDevice.test.tsx
git commit -m "feat(web): usePushDevice subscription lifecycle hook"
```
---
### Task 5: NotificationsSettings — push control + device list
**Files:**
- Modify: `web/src/pages/settings/NotificationsSettings.tsx`
- Test: extend the page's test if one exists (check `ls web/src/pages/settings/*.test.tsx`)
- [ ] **Step 1: Add a push section**
Read the current `NotificationsSettings.tsx` (it uses `SettingsGroup`/`SettingRow`/`Switch`). Add, below the category-preference group, a new "Push notifications" group:
- A row driven by `usePushDevice()`:
- `status === "unsupported"` → static text "This browser doesn't support push notifications."
- `status === "blocked"` → text "Notifications are blocked for this site. Re-enable them in your browser's site settings."
- else → a `Switch` checked when `status === "on"`, disabled when `status === "pending"`, `onCheckedChange` calls `enable()` / `disable()`.
- Below it, a device list from `usePushDevices()`: one row per device (`name || device_id`, `platform`/`transport`, `registered_at`), each with a `Switch` bound to `useTogglePushDevice().mutate({ deviceId, enabled })` reflecting `push_enabled`. Empty state: "No devices registered for push yet."
Match the page's existing component composition and classes. Keep `content_digest` and the existing toggles untouched.
- [ ] **Step 2: Test (if the page is tested)**
If `NotificationsSettings.test.tsx` exists, mock `usePushDevice` (return `{status:"off", enable, disable}`) and `usePushDevices` (two devices); assert: toggling the device-level switch calls `useTogglePushDevice().mutate` with the device id; the main push switch calls `enable` when off. If no sibling test exists, rely on typecheck + the hook tests and note it in the commit.
- [ ] **Step 3: Typecheck, test, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/settings 2>&1 | tail -4
git add web/src/pages/settings/NotificationsSettings.tsx web/src/pages/settings/*.test.tsx
git commit -m "feat(web): push on-this-device control and device list in settings"
```
---
### Task 6: Admin PushSettings page + nav
**Files:**
- Create: `web/src/pages/admin-settings/PushSettings.tsx`
- Modify: `web/src/pages/admin-settings/AdminSettingsLayout.tsx`
- Test: `web/src/pages/admin-settings/PushSettings.test.tsx`
- [ ] **Step 1: Build the page**
Template: `IntegrationsSettings.tsx` (credential card = `CredentialStatus` badge + `SettingField` + `useUpdateServerSetting` + `useAdminSensitiveStatus`). Three cards. Header badge per card from `usePushStatus()`.
- **Web Push card** (leads): fields `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A "Generate keys" button → `useGenerateVapidKeys().mutateAsync()` fills the public + private field state (local `useState`, not auto-saved). Subject field hint: `mailto:you@example.com`. Each field saves via `updateSetting.mutateAsync({ key, value })`. A "Send test push" button → `useSendTestPush().mutate()`.
- **APNs card**: `push.apns.p8_key` (textarea via `SettingField` multiline if supported, else a `<textarea>`), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
- **FCM card**: `push.fcm.service_account_json` (textarea).
`configured` per field from `new Set(sensitive?.configured ?? []).has(key)`. Page header + intro like IntegrationsSettings.
- [ ] **Step 2: Register nav**
In `AdminSettingsLayout.tsx`, add to the "Connections" group's `items` (import the component + a lucide icon, e.g. `BellRing`):
```tsx
{ id: "push", label: "Push Notifications", icon: BellRing, component: PushSettings },
```
(`id: "push"` is stable URL state.)
- [ ] **Step 3: Test — `PushSettings.test.tsx`**
Mock `useUpdateServerSetting` (capture mutate args), `useAdminSensitiveStatus` (some keys configured), `usePushStatus` (webpush true), `useGenerateVapidKeys` (returns a pair), `useSendTestPush`. Assert: three cards render; webpush badge shows ready; saving the VAPID public field calls `updateSetting.mutateAsync({ key: "push.webpush.vapid_public", value })`; "Generate keys" populates the public/private fields; "Send test push" calls the mutation. Follow the admin-settings test conventions (check a sibling `*.test.tsx` in that dir; if none, write a minimal RTL test with the mocks).
- [ ] **Step 4: Typecheck, test, build, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/admin-settings 2>&1 | tail -5
git add web/src/pages/admin-settings/PushSettings.tsx web/src/pages/admin-settings/PushSettings.test.tsx web/src/pages/admin-settings/AdminSettingsLayout.tsx
git commit -m "feat(web): admin push provider config page"
```
---
### Task 7: Full verification + manual smoke
- [ ] **Step 1: Web gates**
```bash
cd web && pnpm run lint 2>&1 | tail -3 && pnpm run format:check 2>&1 | tail -2 && pnpm exec vitest run 2>&1 | tail -5 && pnpm run build 2>&1 | tail -3
```
All green (lint warnings allowed, errors not). Fix any new-code lint/format issues (prettier --write the new files if format:check flags them). `build` also confirms `public/sw.js` is copied to `dist/` (Vite copies `public/` verbatim).
- [ ] **Step 2: Backend gates**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush 2>&1 | tail -3 && make verify-local-paths
```
- [ ] **Step 3: Manual browser smoke (documented; requires a real browser over HTTPS or localhost)**
Service workers + Push require a secure context — `localhost` qualifies. Build the server with the web assets and run on a localhost port.
Commands assume the repository root is the cwd.
```bash
cd web && pnpm run build && cd ..
GOWORK=off go build -o ./bin/silo-pushweb ./cmd/silo && (PORT=18080 JF_PORT=18096 ./bin/silo-pushweb > ./silo-pushweb.log 2>&1 &)
```
Then in a Chromium browser at `http://localhost:18080`:
1. Log in as admin. Go to Admin → Settings → Push Notifications. Click "Generate keys", Save the VAPID public/private + a `mailto:` subject. Confirm the Web Push badge flips to "Ready" (and `GET /admin/push/status` → webpush:true).
2. Go to Settings → Notifications. Toggle "Push on this device" on → grant the browser permission prompt. Confirm a row appears in the device list and the toggle stays on.
3. Click "Send test push" on the admin page (or create an announcement). Within ~30–45s (grace + worker tick) a desktop notification banner appears. Close the app tab first to prove background delivery.
4. Click the banner → the app opens/focuses at the deep link.
5. Toggle push off → confirm `DELETE` and the device drops from the list.
Record the outcome. (If a real browser isn't available in the harness, run steps 1–2 via curl to confirm the API wiring — register a webpush device, confirm `push_deliveries` row + status — and note the visual steps as operator-verified.)
- [ ] **Step 4: Commit any verification fixes; report.** Work stays on `feat/core-notifications-server` (or a dedicated branch off it).
---
## Self-review notes (resolved during writing)
- `webpush.GenerateVAPIDKeys()` returns `(privateKey, publicKey, err)` in webpush-go — Task 1 Step 1 flags verifying the order so the keys aren't swapped.
- The SW's pure logic is duplicated between `public/sw.js` (untestable static file) and the tested `src/lib/pushSw.ts`; the duplication is small and intentional, with sw.js annotated as mirroring the tested source. The reviewer should diff the two functions for drift.
- `usePushDevice` owns register/revoke (tied to the live subscription); standalone query hooks (`push.ts`) cover read/list/toggle/admin. No overlap.
- Test push reuses `notifications.Service.CreateSystem` (Phase-1) → a real system notification → exercises the full enqueue→worker→transport pipeline to the caller's own devices. No bespoke push path.
- Admin credential writes use the existing `PUT /admin/settings/{key}` (encrypted, redacted) — no new write endpoint; only keygen + test are net-new server surface.
- iOS Safari web push (installed-PWA only) is handled by `pushSupported()` returning false in a normal tab → `unsupported` state; documented, not specially coded.
- Several steps embed verify-greps (NewPushHandler call sites, GenerateVAPIDKeys order, SettingField multiline support, sibling test conventions, the user-id context injection helper in push_test.go) — align with reality.
File diff suppressed because it is too large Load Diff
@@ -1,188 +0,0 @@
# Core Notifications System — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
A first-party, whole-system notification layer for Silo: persisted per-user notifications with an inbox, read state, per-category preferences, real-time toasts over the existing event WebSocket, and admin announcements. Ships across the server, web UI, and both mobile apps (in-app only — no APNs/FCM push in this project). External delivery (Discord, email, webhooks) remains the job of the existing `silo.notifications` plugin, which consumes the same events.
## Goals
- Persisted per-user notifications: inbox, unread badge, read/dismiss state.
- Real-time in-app delivery to connected clients via the existing `events.Hub` / `/api/v1/events/ws` pipeline.
- Five categories: `request`, `content`, `announcement`, `system`, `admin` (operational alerts for admin users), plus opt-in `content_digest`.
- Per-category user preferences (announcements not mutable).
- Admin announcements with audience targeting and expiry.
- A documented `notifications.send` event contract so any plugin can create in-app notifications (and, via the existing external plugin's subscription to the same event, external ones).
- Core request lifecycle events (`request.*`) — net-new publishing from `internal/requests`, consumed by notifications and available to the requests UI.
## Non-goals
- Mobile push (APNs/FCM) — future project; nothing here blocks it.
- Request-system unification (`request_router.v1`, migrating ebook/audiobook request plugins onto `media_requests`) — separate future project. When it lands, the book plugins' `notifications.send` publishes are replaced by core `request.*` events with no notifications schema/API change.
- Absorbing external delivery channels into core.
- Fine-grained per-show/per-author subscription rules (watchlist/favorites are the v1 signal).
## Architecture
New package `internal/notifications` in silo-server:
1. **Materializer** — in-process consumer registered on `events.Hub` at startup (same pattern as `internal/plugins/event_dispatcher.go`). Runs events through per-category matchers; writes `notifications` rows for each recipient.
2. **Store** — Postgres repository: insert (idempotent), list (cursor), unread count, mark read, dismiss, preferences CRUD.
3. **Notifier** — after insert, publishes `notification.created` on a new `ChannelNotifications` event channel, user-scoped like `ChannelUserState`. The WS handler (`internal/api/handlers/events_ws.go`) adds this channel to the per-user subscribe set; frames carry the full notification object. Subscribe-time snapshot carries unread count only.
4. **API handlers** — REST endpoints below.
5. **Service API** — `notifications.Create(...)` for direct in-process callers (system/security events).
Creation is DB-first and synchronous: a notification exists when the row commits. WS fan-out is best-effort; clients that miss frames reconcile on next fetch. No delivery queue or retry in core — "delivery" is reading your own table.
## Data model
One migration, three tables:
```sql
notifications (
id bigserial PRIMARY KEY,
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id text NULL REFERENCES user_profiles(id) ON DELETE CASCADE, -- NULL = user-wide
category text NOT NULL, -- 'request' | 'content' | 'announcement' | 'system' | 'admin'
type text NOT NULL, -- e.g. 'request.approved', 'content.watchlist_episode'
title text NOT NULL,
body text NOT NULL,
link text NULL, -- in-app deep link
item_id bigint NULL, -- optional content reference (artwork, dedup)
source_event text NULL, -- originating event name (audit)
dedup_ref text NULL, -- idempotency: UNIQUE (user_id, type, dedup_ref) WHERE dedup_ref IS NOT NULL
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz NULL,
dismissed_at timestamptz NULL,
expires_at timestamptz NULL
);
-- index: (user_id, created_at DESC) partial WHERE dismissed_at IS NULL
-- partial unique index for dedup as above; inserts use ON CONFLICT DO NOTHING
notification_preferences (
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category text NOT NULL, -- 'request' | 'content' | 'system' | 'admin' | 'content_digest'
enabled bool NOT NULL,
PRIMARY KEY (user_id, category)
);
-- absent row = enabled, except content_digest which defaults OFF
-- 'announcement' is not a valid category here (not mutable)
announcements (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
audience jsonb NOT NULL, -- {"all":true} | {"user_ids":[...]} | {"library_ids":[...]}
created_by int REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NULL
);
```
Announcements fan out to `notifications` rows at publish time (audience resolved then), so the inbox is always a single-table query and read state is per-user.
Child profiles: rows with `category IN ('request','system','admin')` are filtered out of child-profile sessions server-side; content matching never targets a child profile unless the matched watchlist/favorite belongs to that profile.
Retention (nightly job, existing scheduler pattern): dismissed rows purged after 30 days; all rows after 90 days; unread rows from expired announcements at expiry.
## API
User endpoints (authenticated, profile from claims):
```
GET /api/v1/notifications ?unread=1&category=&cursor=&limit=
GET /api/v1/notifications/unread-count → {count}
POST /api/v1/notifications/read {ids:[...]} | {all:true}
POST /api/v1/notifications/{id}/dismiss
GET /api/v1/notifications/preferences
PUT /api/v1/notifications/preferences {category: enabled, ...}
```
List returns user-wide rows plus active-profile rows, newest first, cursor-paginated in the standard envelope. Child-profile filtering server-side.
Admin endpoints (RequireAdmin group):
```
GET /api/v1/admin/announcements
POST /api/v1/admin/announcements {title, body, audience, expires_at?}
DELETE /api/v1/admin/announcements/{id} -- also dismisses unread fanned-out rows
```
## Events
### New: core request lifecycle events
`internal/requests/service.go` and the reconciliation task publish on a new `ChannelRequests`:
- `request.submitted`, `request.approved`, `request.declined`, `request.completed`, `request.failed`
- Payload: `request_id, user_id, profile_id, title, media_type, quality, status`
`ChannelRequests` is added to WS subscribe sets (requesting user + admins), giving the requests UI live updates as a side benefit. This is the only change to the requests subsystem in this project.
### New: `notifications.send` plugin contract
The materializer consumes `notifications.send` events:
```json
{ "user_id": 1, "profile_id": null, "category": "request",
"type": "request.fulfilled", "title": "...", "body": "...",
"link": null, "dedup_ref": "ebook-req-123:fulfilled" }
```
Payloads are validated; malformed events are dropped with a logged warning. The external `silo.notifications` plugin already subscribes to this event name, so one publish reaches both in-app and external channels. The ebook/audiobook request plugins each add one `PublishEvent("notifications.send", ...)` at their acknowledged/fulfilled/failed transitions (their existing status events do not carry `user_id`; the plugins own that context).
### Matchers
- **request** — consumes core `request.*` events only. Dedup ref `(request_id, status)`.
- **content** — consumes `library.item_added` / `catalog.library.changed`. For each added item: resolve series/author; match per profile against watchlist, favorites (author/series link), and in-progress series (last play within 21 days); union, dedup, filter by library access. Burst guard: one notification per series per profile per hour (season imports collapse).
- **content_digest** — nightly job (not event-driven), opt-in: one row per user summarizing the previous day's additions to accessible libraries.
- **system** — not matched from the hub; emitted via `notifications.Create()` directly at the source (password change, new-device login, guest-pass redemption). Sites also publish their events as today; the explicit call means security notices never depend on matcher wiring.
- **announcement** — fan-out at publish time.
- **admin** — consumes existing `ChannelJobs` / `ChannelScans` / `ChannelTasks` failure events plus plugin-host crash/install failures; targets admin users; repeat-failure throttle (same source, max one per hour).
Preference checks run in the materializer before insert (cached per-user prefs lookup).
## Web UI
1. **Bell + dropdown** in the top bar: unread badge from `unread-count`, kept live by `ChannelNotifications` frames via the existing `RealtimeEventsProvider` socket. Dropdown: latest ~10, mark-all-read, deep links.
2. **Toasts** — WS notification frames fire sonner toasts; suppressed while the dropdown is open or when the frame targets another profile.
3. **Inbox page** (`/notifications`) — paginated list, category filter tabs, dismiss, mark-read on view.
4. **Settings** — per-category toggles (`content_digest` default off; `admin` visible to admins only). Admin: Announcements page (list/compose/expire) under admin settings.
## Mobile (in-app, both platforms)
Same three features in silo-apple and silo-android against the frozen REST API: inbox screen (list, mark-read, dismiss), unread badge on existing navigation, preferences screen. Transport: REST refresh of unread-count + first page on app foreground and pull-to-refresh. No WS subscription in v1 (current app sockets are playback-scoped; no background delivery exists without push anyway). TV variants: badge + read-only inbox, no preferences editing.
## Error handling
- Matcher isolation: a panic/error in one matcher logs and skips that event for that matcher only; never blocks the hub or other matchers (same containment as the plugin event dispatcher).
- Idempotent inserts via the dedup partial unique index + `ON CONFLICT DO NOTHING`; replayed events cannot double-notify.
- WS fan-out is fire-and-forget; DB rows are the source of truth.
- `notifications.send` validation failures: drop + warn (plugin bugs must not break core paths).
## Testing
- Unit tests per matcher with synthetic events; content-matcher edge cases: child profiles, library access, burst guard, dedup.
- Store tests against Postgres (repo convention).
- One integration test: event → materializer → row → WS frame through a real hub.
- API handler tests: auth, profile scoping, child filtering, admin gates.
- Web: component tests for bell/inbox; extend the RealtimeEventsProvider test pattern for the new channel.
## Implementation sequencing
1. **Server** — migration, store, materializer + matchers, request event publishing, API, WS channel, retention job.
2. **Web** — bell, toasts, inbox, settings, announcements admin.
3. **Mobile** — apple + android in-app surfaces against the frozen API.
4. (Separate projects, later: request unification via `request_router.v1`; mobile push.)
## Future work
- Request unification: core `request.*` events replace the book plugins' `notifications.send` publishes; notifications unchanged.
- Push: APNs/FCM device registry; the notifier gains a push fan-out alongside WS.
- Digest scheduling preferences (weekly, time-of-day).
- Profile claims enrichment: embed `is_child` in profile-scoped auth claims. Fixes two implementation-time findings: the WS pipeline cannot child-filter (snapshot and live frames use client-asserted profile identity; REST remains authoritative and fails closed — see the snapshot comment in `internal/api/handlers/events_ws.go`), and the REST `childSafe` check costs one profile lookup per request.
- Admin-alert coverage: only `job.failed` and `scan.failed` events exist today. `task.failed` and plugin-host crash events are not published by their subsystems yet; when they are, the admin matcher needs only a map entry.
- Dedicated `item_added` catalog event: the content matcher keys on `catalog.item.changed` (`metadata_updated`) plus a 48-hour `media_items.created_at` recency gate, because the scanner publishes no per-item add event. An explicit ingest-time event would remove the proxy heuristic.
- New-device login notifications: deferred from v1 (no device-fingerprint store exists); password-change notifications shipped.
@@ -1,173 +0,0 @@
# Core Push Notifications — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
A server-side push subsystem that mirrors the in-app notifications created in Phase 1 out to registered devices via APNs (Apple), FCM (Android), and Web Push (browsers). Delivery is durable (a DB-backed queue with a retry worker) and presence-gated (a notification only pushes to devices whose user has no live WebSocket connection after a short grace delay). This is a core-server project: the mobile clients have no push code yet, so the deliverable is the registry, queue, worker, transports, and APIs — built transport-agnostic so APNs/FCM are drop-in alongside the immediately-testable Web Push.
## Goals
- Deliver Phase-1 notifications to devices over APNs, FCM, and Web Push.
- Durable delivery: survive restarts, retry with backoff, prune dead tokens, observable in the admin task view.
- Presence-gated: don't buzz a device for a notification the user is actively seeing in-app.
- Reuse the existing per-category mute preferences; add per-device push on/off. No new per-channel matrix.
- Encrypted provider credentials via the existing settings mechanism.
- Transport-agnostic core: one `Transport` interface, three implementations, selected by device platform.
## Non-goals
- Mobile/web client integration (separate per-client projects; this ships the server contract).
- A separate in-app-vs-push preference matrix (category mutes + per-device toggle only).
- Rich push (images, action buttons) — title/body/link/category payload only in v1.
- New-device-login / security pushes beyond what the notification categories already produce.
## Architecture
New `internal/push` package and a new `internal/presence` package, plus touches to three existing seams (`notifications.Service.Create`, the events WS handler, `cmd/silo/main.go` wiring). Five independently-testable units:
1. **Device/token registry** — extends `user_devices` with push columns; a registration endpoint clients call to enroll/refresh/revoke a token and toggle per-device push. Owns "which devices want push and how to reach them."
2. **Presence registry** (`internal/presence`) — process-local `user_id → live-connection refcount`, incremented/decremented by the events WS handler on connect/disconnect. Answers "does this user have a live connection right now?" Cluster-aware via a short-TTL Redis key; degrades to per-node if Redis is absent.
3. **Enqueuer** — called from `notifications.Service.Create()` after the row commits. Resolves push-eligible devices, writes one `push_deliveries` row per device with `not_before = now + grace`.
4. **Delivery worker** — a `PushDeliveryTask` on the existing task framework; claims due rows, re-checks presence, dispatches via the platform's transport, records outcome, prunes dead tokens.
5. **Transports** — a `Transport` interface (`Send(ctx, target, payload) → Result`) with APNs, FCM, and Web Push implementations selected by device platform; credentials from the encrypted settings repo.
Data flow: `Create()` → enqueuer writes deliveries → worker (interval) claims due & un-present → transport sends → outcome / dead-token prune. The in-app path (immediate WS publish) is untouched; push is the deferred, gated mirror.
### Seam reference (current code)
- Fan-out hook: `internal/notifications/service.go` `Create()` — after `created, err := s.store.Insert(...)` succeeds and before/alongside the `hub.PublishJSON(... "notification.created" ...)` call. Enqueue is best-effort and must not block or fail `Create()`.
- Presence increment: `internal/api/handlers/events_ws.go` — after a successful subscribe, `release := presence.Add(claims.UserID)`, `defer release()`.
- No presence tracking exists today (confirmed); the registry is net-new.
- `user_devices` exists (`migrations/sql/180_user_devices.sql`, PK `user_id, profile_id, device_id`) with no token column.
- Credential encryption: `internal/catalog/encrypted_settings_repo.go` `SensitiveSettingKeys`.
- Background-worker precedent: `internal/taskmanager/tasks/*` (e.g. the request reconciler, notifications digest/retention).
## Data model
Extend `user_devices` (a push token belongs to a device):
```sql
ALTER TABLE user_devices
ADD COLUMN push_token text NULL,
ADD COLUMN push_transport text NULL, -- 'apns' | 'fcm' | 'webpush'
ADD COLUMN push_enabled boolean NOT NULL DEFAULT true,
ADD COLUMN push_token_at timestamptz NULL,
ADD COLUMN push_failures integer NOT NULL DEFAULT 0;
```
A device is push-eligible when `push_token IS NOT NULL AND push_enabled`. For `webpush`, `push_token` holds the JSON subscription object (endpoint + p256dh + auth); APNs/FCM store the bare token string. Each transport interprets its own format.
Delivery queue:
```sql
CREATE TABLE push_deliveries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
notification_id bigint NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
user_id integer NOT NULL,
device_id text NOT NULL,
transport text NOT NULL,
status text NOT NULL DEFAULT 'pending', -- pending|sent|failed|skipped|dead
attempts integer NOT NULL DEFAULT 0,
not_before timestamptz NOT NULL,
last_error text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX push_deliveries_claim_idx
ON push_deliveries (not_before)
WHERE status IN ('pending','failed');
```
Worker claim: `status IN ('pending','failed') AND not_before <= now()`, ordered by `not_before`, `FOR UPDATE SKIP LOCKED` (safe across nodes/concurrent workers).
Outcomes:
- **sent** — terminal success.
- **skipped** — user was present (live WS) at claim time; the in-app delivery sufficed. Terminal.
- **failed** — soft error (timeout, 5xx, rate-limit); bump `attempts`, push `not_before` out by the backoff schedule (1m, 5m, 30m). Once the schedule is exhausted (4 total attempts) → `dead`.
- **dead** — hard error / unregistered token; terminal. Also clears `push_token` and sets `push_enabled=false` on the `user_devices` row so the dead device stops being enqueued.
Deliveries do not duplicate notification content — the worker joins `notifications` at send time so an edited/expired notification sends current content (and an expired/dismissed one can be skipped). Retention: terminal deliveries older than 7 days are deleted by a pass on the existing notifications retention task.
Provider config (added to `SensitiveSettingKeys`, encrypted at rest):
`push.apns.p8_key`, `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`, `push.fcm.service_account_json`, `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A transport with missing/blank config reports itself unconfigured; its deliveries short-circuit to `skipped` (reason: unconfigured) rather than erroring in a loop.
## API
Device push registration (authenticated user routes; device identified by the existing `X-Silo-Device-Id` header + `user_id` from claims — never from the body):
```
PUT /api/v1/notifications/push/device {transport, token} → 204
enroll/refresh this device's token (upsert onto the user_devices row);
resets push_failures, sets push_enabled=true, push_token_at=now
DELETE /api/v1/notifications/push/device → 204
revoke: clear token, disable push for this device
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,push_enabled,registered_at}]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
per-device on/off toggle
GET /api/v1/notifications/push/webpush-key → {vapid_public_key} (empty when web push unconfigured)
```
Admin provider config uses the existing admin settings mechanism (encrypted `push.*` keys) — no bespoke write endpoints. One read for the admin UI:
```
GET /api/v1/admin/push/status → {apns:{configured:bool}, fcm:{configured:bool}, webpush:{configured:bool}}
```
Status reports booleans only; secrets are never returned.
## Presence registry
`presence.Registry` interface: `Connected(userID int) bool` and `Add(userID int) (release func())`. The events WS handler calls `release := registry.Add(claims.UserID)` after a successful subscribe and `defer release()` on disconnect.
- In-process implementation: `map[int]int` refcount under a mutex.
- Cluster-aware implementation: `INCR push:presence:{userID}` in Redis with a 60s TTL refreshed on the WS heartbeat; `DECR` on release; `Connected` is key `> 0`. Redis is already used by the events hub.
- Presence failure (Redis down) fails **open** — we push — because a missed push is worse than a redundant one.
The worker re-checks `Connected` at claim time; a present user's due delivery becomes `skipped`. The `not_before` grace (default 30s) means an actively-in-app user almost never gets a redundant push, while a user who closed the app moments ago does.
## Error handling & delivery semantics
- Enqueuer is best-effort; failure to write deliveries logs and returns. `Create()` is never blocked or failed by push.
- Worker isolates per-delivery: one device's failure never blocks the batch.
- Soft errors → `failed` + exponential backoff on `not_before` (1m, 5m, 30m), then `dead` after 4 total attempts.
- Hard errors (`Unregistered`/`InvalidToken`/FCM `NOT_FOUND`) → immediate `dead` + clear token + disable push on `user_devices`.
- Unconfigured transport → its deliveries short-circuit to `skipped` (reason logged once, not per-row spam).
- Rate-limit responses park that transport briefly (honor `Retry-After`).
- Child-profile suppression applied at enqueue: categories in {request, system, admin} are never enqueued for a child profile (same rule as the web toast suppression).
- APNs/FCM payloads set a collapse/thread key = notification id to avoid duplicate banners across retries.
## Security
- Provider secrets live only in the encrypted settings repo; never logged, never returned by any read endpoint. `/admin/push/status` reports booleans.
- Token registration is authenticated and device-scoped to the caller's `user_id` (from claims).
- Push payload carries title/body/link/category + notification id — no content beyond what the notification already holds.
## Testing
- Worker tested with a fake `Transport` asserting outcome transitions: sent, skipped (present), failed-with-backoff, dead-prunes-token. No network.
- Each real transport unit-tested against a mocked HTTP endpoint (request shape, auth header, error-code mapping) — standard repo pattern.
- Enqueuer tested with a fake device store + presence stub: eligible-device resolution, child suppression, per-device disable, `not_before` grace.
- Presence registry: refcount add/release, Connected transitions, Redis-absent fallback.
- Store tests against Postgres: claim query with `SKIP LOCKED`, dead-token prune, retention.
- One integration test: `Create() → delivery row → worker → fake transport → sent`.
- Handler tests: registration auth-scoping, per-device toggle, `/admin/push/status` unconfigured states, `webpush-key` empty when unconfigured.
## Implementation sequencing
1. Migration (user_devices columns + push_deliveries) and domain types.
2. `internal/presence` registry (in-process + Redis) and WS-handler wiring.
3. Device registry store + registration/toggle endpoints.
4. Enqueuer + hook into `Create()`.
5. `Transport` interface + fake; delivery worker + task registration; outcome/backoff/dead-prune.
6. Real transports (Web Push first — end-to-end testable today; then APNs, then FCM) + encrypted config keys + `/admin/push/status`.
7. Retention pass + full verification.
## Future work
- Client integration: Apple (APNs token registration + handling), Android (FCM), web (service worker + VAPID subscription). Each binds to the frozen registration API.
- Presence-aware in-app→push escalation tuning (per-category grace, quiet hours).
- Rich push (images from `item_id` artwork, action buttons).
- Per-channel preference matrix if category+device proves too coarse.
- Shared-device push semantics: a physical device registers one OS token but one user_devices row per profile. For user-wide notifications, EligibleDevices currently returns one delivery per profile-row → the same token is sent to more than once (the per-notification collapse key dedupes the on-device banner, so the user sees one, but the sends are redundant). And child-category suppression on a device shared by a child and an adult profile is ambiguous because push cannot know the active profile at send time. Both resolve with the active-profile/claims-enrichment work also needed for WS child-filtering: carry the active profile identity so EligibleDevices can dedupe by token and apply the correct profile's suppression.
@@ -1,120 +0,0 @@
# Core Push — Web Client & Admin Config — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
The core-buildable slice of Phase 3 (client work): a Web Push browser client and an admin push-provider config UI, both living in the silo-server web app plus one ~10-line Go endpoint. This makes the push subsystem operable end to end from the browser — an operator enters provider credentials in an admin page, a user enables push on their browser, and notifications arrive as OS banners with the app closed. The mobile clients (silo-apple APNs, silo-android FCM) remain out of scope; they are separate per-repo projects that consume the same frozen registration API.
This delivers user-facing notification delivery to browsers. It is not a plugin-communication channel; plugins relate to notifications only via the existing `notifications.send` event contract (a plugin produces a user notification, which then flows to in-app + push).
## Goals
- Web Push delivery to browsers: service worker, subscription, receive, click-through.
- A per-device "push on this device" control and a device-management list in user settings.
- An admin page to configure APNs / FCM / Web Push provider credentials with readiness status.
- VAPID key generation from the admin UI.
- Reuse everything already built: the frozen push registration API, the encrypted settings write path, the Phase-2 notifications settings page and api client.
## Non-goals
- Mobile client integration (silo-apple / silo-android) — separate projects against the frozen API.
- A PWA framework / offline caching (vite-plugin-pwa) — a static service worker covers push; offline is out of scope.
- Any change to the push delivery worker, transports, or registration API (all frozen).
- Rich push (images, action buttons).
## Frozen dependencies (already on this branch)
Push registration API (`internal/api/handlers/push.go`, routes in `router.go`):
```
PUT /api/v1/notifications/push/device {transport, token} → 204 (RequireProfile)
DELETE /api/v1/notifications/push/device → 204 (RequireProfile)
GET /api/v1/notifications/push/devices → {devices:[DeviceInfo]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
```
`DeviceInfo`: `{device_id, name, platform, transport, push_enabled, registered_at}`. The api client (`web/src/api/client.ts`) auto-attaches `X-Silo-Device-Id` and `X-Profile-Id`. WebPush tokens are stored as the JSON subscription object.
Admin settings write path: `PUT /api/v1/admin/settings/{key}` `{value}` (`admin.go` HandleUpdateSetting) — all `push.*` keys are pre-registered sensitive in `encrypted_settings_repo.go` (encrypted at rest, value redacted from reads). No new write endpoint needed. Sensitive-status hook (`useAdminSensitiveStatus`) reports which keys are stored.
Web push server transport already sends payload `{id, title, body, link, category}` with `Topic: "n"+base36(notificationID)` for collapse.
## Architecture
Six units (five web, one tiny Go endpoint):
1. **Service worker** — `web/public/sw.js` (static). `push` → `showNotification` from payload; `notificationclick` → focus an open app tab (postMessage the link) or `openWindow(link)`; `pushsubscriptionchange` → re-subscribe with the cached VAPID key and re-register (best-effort token refresh).
2. **SW registration** — in `web/src/main.tsx`, registers `/sw.js` when `serviceWorker` + `PushManager` are supported; no-op otherwise.
3. **`usePushDevice` hook** — `web/src/hooks/usePushDevice.ts`. Owns the browser-push lifecycle and exposes a status state machine + `enable()`/`disable()`.
4. **Settings UI** — extend `web/src/pages/settings/NotificationsSettings.tsx`: "Push on this device" control + a device-management list (per-device enable/disable).
5. **Admin push-config page** — `web/src/pages/admin-settings/PushSettings.tsx`: three credential cards + VAPID keygen + readiness badges.
6. **VAPID keygen endpoint** — `POST /api/v1/admin/push/generate-vapid-keys` (RequireAdmin), ~10 lines in `push.go`. The only net-new server code.
## Push client data flow
`usePushDevice` reports `status`:
- **unsupported** — no serviceWorker/PushManager, or iOS Safari not installed as a PWA. UI explains; no action.
- **blocked** — `Notification.permission === "denied"`. UI explains how to re-enable in site settings (cannot un-deny programmatically).
- **off** — supported, not subscribed. Toggle → `enable()`.
- **on** — subscribed and registered. Toggle → `disable()`.
- **pending** — transition in flight; control disabled.
`enable()`: `Notification.requestPermission()` → if granted, `navigator.serviceWorker.ready` → `GET /notifications/push/webpush-key` → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublic) })` → `PUT /notifications/push/device { transport: "webpush", token: JSON.stringify(subscription.toJSON()) }`. The api client supplies device + profile headers (satisfies the route's RequireProfile). Cache the VAPID public key (Cache Storage / IndexedDB) so the SW can re-subscribe on `pushsubscriptionchange` without the app open. Success → `on`, toast.
`disable()`: `pushManager.getSubscription()` → `unsubscribe()` (best-effort) → `DELETE /notifications/push/device` → `off`, toast.
Receiving (SW): payload `{id,title,body,link,category}` → `showNotification(title, { body, data:{link}, tag:"n"+id })`. The `tag` is an on-device display-dedup keyed on the notification id (a redelivery of the same notification replaces the visible banner rather than stacking); it is independent of the server's Web Push `Topic` header (which dedups in the push-service queue). `notificationclick` → focus matching-origin client + postMessage `{type:"notification-click", link}`, else `openWindow(link ?? "/notifications")`.
Idempotency / healing: `enable()` is safe to re-run (server upserts); a stale local subscription with no server row heals on re-enable. Permission-granted-but-subscribe-fails → toast, stay `off`.
## Admin config page
`PushSettings.tsx`, registered in `AdminSettingsLayout` `SETTINGS_GROUPS` (Connections group). Pattern copied from `IntegrationsSettings.tsx` (`SettingField` + `CredentialStatus` + `useUpdateServerSetting`). Cards:
- **Web Push** (leads — usable today): `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. "Generate keys" button → `POST /admin/push/generate-vapid-keys` → fills public/private fields (not auto-saved; admin reviews then saves each). Subject defaults to a `mailto:` hint.
- **APNs**: `push.apns.p8_key` (textarea), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
- **FCM**: `push.fcm.service_account_json` (textarea).
Readiness: card header badge from `GET /admin/push/status`; per-field "configured, leave blank to keep" from the sensitive-status hook (secrets never returned). Each save = `PUT /admin/settings/{key}` `{value}` via `useUpdateServerSetting`; encryption automatic; badges refetch after save.
VAPID keygen endpoint: generates and returns `{vapid_public, vapid_private}`; does not persist. Handler test: non-empty pair, admin-gated.
### Open decision — "Send test push" button
OPTION (admin's call at spec review): a "Send test push" button that creates a one-off system notification addressed to the admin themselves to verify config→subscribe→receive end to end from the UI. Requires one more net-new server endpoint (`POST /api/v1/admin/push/test` → `notifications.Create` a system notification for the caller). Recommended for operability (turns "did I configure this right?" into one click) but it is the only other net-new server surface. **Decision: include it** unless cut at review. (Implementation plan will gate this behind the decision.)
## Error handling
- Capability gating (unsupported, denied) are UI states, not errors; iOS Safari shows "Add to Home Screen to enable push."
- Transient subscribe/register failures → toast, leave `off`; nothing half-committed (server PUT is source of truth, idempotent).
- SW resilience: tolerate partial payload (generic title fallback); `notificationclick` falls back to `/notifications`; failed `pushsubscriptionchange` re-register logs and retries on next app load (browser stops receiving until reopened — never a crash).
- Admin page: save failures toast; readiness badges always reflect server truth (refetch after save); partial config shows "Not configured" until complete; generate-keys failure leaves fields untouched.
## Testing
- `usePushDevice` — unit tests with mocked serviceWorker/PushManager/Notification/api client: status derivation, enable() happy path (permission→key→subscribe→PUT body), permission-denied short-circuit, disable() (unsubscribe + DELETE), idempotent re-enable.
- Settings UI — component tests: each status renders the right control; device-list rows + per-device toggle mutation.
- Service worker — vitest over the pure handlers: payload→showNotification-args mapping, click→target-URL logic. DOM wiring exercised in the manual smoke.
- Admin page — component tests: three cards render, badges reflect `/admin/push/status`, save calls `PUT /admin/settings/{key}` with the right key, generate-keys fills fields.
- Server — VAPID keygen handler test (non-empty pair, admin-gated); test-push endpoint handler test (if included).
- Manual smoke (documented in plan): real browser — configure VAPID, enable push, trigger a notification, confirm OS banner with app closed, click → deep link.
## Implementation sequencing
1. Server: VAPID keygen endpoint (+ test-push endpoint if kept).
2. Web: service worker + registration + VAPID-key caching.
3. Web: `usePushDevice` hook + tests.
4. Web: NotificationsSettings push control + device list.
5. Web: admin PushSettings page + nav.
6. Verification + manual browser smoke.
## Future work
- Mobile clients (APNs/FCM) against the frozen API.
- Shared-device token dedup + active-profile child-suppression (recorded in the push server spec) — informs whether multiple browsers/profiles dedupe.
- Per-category push targeting if category mutes prove too coarse.
- PWA/offline (vite-plugin-pwa) if the app later wants installable offline behavior.
@@ -0,0 +1,170 @@
# Tiered Security Model: Group-Based Permissions — Design
Date: 2026-06-10
Status: Approved design, pending implementation plan
Commands and paths in this document assume the repository root is the cwd.
## Overview
Replace Silo's per-user authorization fields with a group-based model:
- Admins create **groups**; each group carries a full policy (feature permissions, library access, playback/transcode limits, download rights).
- Users are assigned to one or more groups; effective policy is the most-permissive union of their groups.
- Groups are the **only** source of policy. Per-user policy columns are removed.
- Two protected built-in groups ship by default: `administrators` and `users`. New accounts join the group(s) named by a server setting (initially `users`).
- The `users.role` column and JWT role claim are removed; "admin" becomes a permission granted by group membership.
Deployments may have **thousands of users**. All fan-out paths (revision bumps, member listings, migration artifacts) are designed set-based and paginated accordingly.
## Current state (what this replaces)
- `users.role` text (`"admin"`/`"user"`), checked by `RequireAdmin` middleware via JWT `Claims.Role` (`internal/api/middleware/auth.go`).
- `users.permissions text[]` with two assignable permissions (`marker_edit`, `metadata_curation`) defined in `internal/auth/permissions.go`; admins implicitly hold all.
- Per-user resource policy columns on `users`: `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
- `users.access_policy_revision` invalidates profile tokens via `internal/access/resolver.go`.
- Household profiles (`user_profiles`) carry a separate restriction layer (child flag, content rating, allowed libraries). **Profiles are out of scope** — that layer is unchanged and continues to apply on top of the user's effective policy.
## Data model
### `groups`
```sql
CREATE TABLE groups (
id serial PRIMARY KEY,
slug text NOT NULL UNIQUE, -- stable identifier: 'administrators', 'users', ...
name text NOT NULL, -- display name, renamable
description text NOT NULL DEFAULT '',
built_in boolean NOT NULL DEFAULT false,
permissions text[] NOT NULL DEFAULT '{}',
library_ids integer[], -- NULL = all libraries
max_streams integer NOT NULL DEFAULT 6,
max_transcodes integer NOT NULL DEFAULT 2,
max_profiles integer NOT NULL DEFAULT 5,
max_playback_quality text NOT NULL DEFAULT '', -- '' = unrestricted
download_allowed boolean NOT NULL DEFAULT true,
download_transcode_allowed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
### `user_groups`
```sql
CREATE TABLE user_groups (
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id integer NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX user_groups_group_id_idx ON user_groups (group_id);
```
### Admin as a permission
`'admin'` is a permission string in `groups.permissions`, not a column. The resolver treats it as implying every permission and unrestricted policy, mirroring the current `HasEffectivePermission` short-circuit for `role == "admin"`. One mechanism covers everything.
Permission catalog at launch: `admin`, `marker_edit`, `metadata_curation`. The catalog stays a Go-level allowlist in `internal/auth/permissions.go`; adding a permission is a code change, as today.
### Built-in groups
| Slug | Permissions | Policy | Protections |
|---|---|---|---|
| `administrators` | `['admin']` | irrelevant (admin implies unrestricted) | not deletable; `admin` permission not removable; last enabled member not removable |
| `users` | `['marker_edit']` | all libraries, 6 streams, 2 transcodes, 5 profiles, downloads on, transcode downloads off | not deletable |
Protections are enforced at the service layer (consistent with existing last-admin guards), not by triggers.
### `users` table changes
Dropped: `role`, `permissions`, `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
Kept: `enabled` (account kill-switch stays user-level), `access_policy_revision` (still the invalidation counter).
### Default group setting
A `server_settings` entry `users.default_group_slugs` (initially `["users"]`), consumed by all account-provisioning paths (signup, invite, OAuth) in `internal/auth/account_provisioner.go`. Invite codes may later override it; out of scope now.
## Resolution semantics
A user's **effective policy** is the most-permissive union of their groups:
| Field | Rule |
|---|---|
| `permissions` | set union; `admin` present → every permission granted |
| `library_ids` | union; any group with `NULL` (all) or `admin` → unrestricted |
| `max_streams`, `max_transcodes`, `max_profiles` | 0 means unlimited at enforcement, so any group with 0 wins; otherwise max across groups |
| `max_playback_quality` | highest wins; `''` (unrestricted) beats everything (ordering as in `internal/access/quality.go`) |
| `download_allowed`, `download_transcode_allowed` | OR |
**Zero groups → empty policy**: no permissions, no libraries (lockout is enforced by the empty library list, so stream/transcode limits are irrelevant; `max_profiles` is floored at 1 since 0 would mean unlimited profile creation). The user can authenticate but sees an empty server. This is deliberate ("groups only grant") and gives admins a soft-disable distinct from `enabled = false`.
**Where computed:** the user repository (`internal/auth/repository.go`) aggregates group policy in the same load path as the user row (a second indexed query over `user_groups`/`groups`, batched for list views; cost is O(memberships per user), independent of total user count). `models.User` keeps its existing policy fields (`LibraryIDs`, `MaxStreams`, `Permissions`, …) but they become **effective** values populated from the aggregate. The ~25 consumer packages (playback, catalog access filters, jellycompat, sections, audiobooks, requests, …) keep working unchanged. `Role` is replaced by `IsAdmin bool` derived from effective permissions. Assigned memberships are exposed separately (`GroupIDs`) for the admin API.
**Invalidation:** unchanged mechanism, new triggers:
- Group policy edit → bump `access_policy_revision` for all members in **one set-based `UPDATE ... FROM user_groups`** (never a per-user loop; the `users` group may have thousands of members).
- Membership add/remove → bump the affected user's revision.
The profile-token revision check in `internal/access/resolver.go` then invalidates stale scopes automatically. The post-bump re-resolution stampede is bounded: each active session re-resolves once with a single indexed query on its next request. No new caching layer until profiling shows a need.
## Enforcement changes
- **JWT claims:** `Claims.Role` removed. Claims carry identity only (`user_id`, session, profile, revision) — never policy. Fixes the existing staleness gap where a role change waited for token re-issuance; after this, revoking admin takes effect on the next request.
- **`RequireAdmin`** (`internal/api/middleware/auth.go`): loads the user (effective policy included) and checks `IsAdmin`; stashes the loaded user in request context so handlers don't re-fetch. One indexed query on low-traffic admin routes.
- **Permission middleware** (`internal/api/middleware/permissions.go`): logic unchanged — it already operates on `models.User`. Only `user.Role == "admin"` comparisons become `user.IsAdmin`.
- **Jellyfin compat** (`internal/jellycompat/handlers_auth.go`): `UserPolicy` maps from effective policy — `IsAdministrator` ← `IsAdmin`, `EnableContentDownloading` ← `DownloadAllowed`, `EnableAllFolders`/`EnabledFolders` ← `LibraryIDs`. Replaces the currently hardcoded policy values.
- **Requests service** (`internal/requests`): role-string comparisons → `IsAdmin`; otherwise unchanged.
## API surface
All new endpoints behind `RequireAdmin`, under the existing admin API namespace:
- `GET /api/admin/groups` — list groups with member counts (single aggregated count query)
- `POST /api/admin/groups` — create; slug auto-derived from name
- `GET /api/admin/groups/{id}` — group detail: policy + member **count** (members are paginated separately)
- `GET /api/admin/groups/{id}/members?offset=&limit=` — paginated member list
- `PATCH /api/admin/groups/{id}` — update name/description/policy; rejects removing `admin` from `administrators`
- `DELETE /api/admin/groups/{id}` — rejects built-ins; memberships cascade
- `PUT /api/admin/groups/{id}/members/{userID}` — add member
- `DELETE /api/admin/groups/{id}/members/{userID}` — remove member; rejects removing the last enabled member of `administrators`
Changed endpoints:
- `POST`/`PATCH` admin user endpoints: per-user policy fields removed from request bodies, replaced by `group_ids`. Responses gain `groups: [{id, slug, name}]`; the existing top-level policy fields (`library_ids`, `max_streams`, …) become read-only effective values derived from group membership.
- User-facing account/session responses keep a **derived** `role` field (`"admin"`/`"user"` from `IsAdmin`) so `silo-android` and `silo-apple` need no immediate changes; group info is added alongside.
Default-group setting rides the existing server-settings endpoints.
## Admin UI (`web/src/pages/admin`)
- New **Groups** page: list (name, member count, key-policy summary) and an editor with three sections — permissions (checkboxes), library access (all / picker, reusing the user editor's component), limits (streams, transcodes, quality, downloads, profiles). Member management with a paginated member list and user search to add.
- **Users** page: per-user policy controls replaced by a group multi-select plus a read-only "effective policy" summary showing the resolved union — the admin's answer to "why can this user do X".
- Built-in groups render with a badge; delete and protected fields disabled with explanatory tooltips.
## Migration
One transactional Goose migration created with `make migrate-create NAME=group_based_permissions`:
1. Create `groups` and `user_groups`; seed `administrators` and `users` (policy from current column defaults).
2. Map members: `role = 'admin'` → `administrators` only. Non-admin users whose policy columns match the defaults → `users`.
3. Preserve outliers **bucketed by distinct policy tuple**: non-admin users whose policy columns deviate from the defaults are grouped by their unique `(permissions, library_ids, max_streams, max_transcodes, max_profiles, max_playback_quality, download_allowed, download_transcode_allowed)` combination; one group per distinct tuple (`migrated-policy-1`, `-2`, …) is created carrying those values, and matching users are added **to that group only — not to `users`**. (Adding outliers to `users` would erase their restrictions via the most-permissive union: `users` grants all libraries, so a user previously limited to specific libraries would become unrestricted.) Group count is bounded by distinct policies in use, not user count. Post-migration effective policy is exactly each user's prior policy; nothing is silently lost or gained. (Admins were already unrestricted, so admin outliers need no preservation.)
4. Drop the old `users` columns; bump all `access_policy_revision` values set-based.
Down-migration recreates the columns and writes back each user's flattened effective policy (best-effort; acceptable at WIP stage).
## Testing
- Union resolver unit tests: multi-group merges, `admin` short-circuit, zero-group empty policy, NULL-libraries propagation, quality ordering (extending `internal/access/quality_test.go` coverage).
- Service-layer guard tests: delete built-in, strip `admin` from `administrators`, remove last enabled administrator.
- Migration test against a seeded DB asserting before/after effective-policy equivalence, including outlier users sharing a policy tuple.
- Handler tests for group endpoints incl. member pagination; updated user-endpoint tests for changed payloads.
- Jellycompat test asserting `UserPolicy` reflects group-derived policy.
## Risks & follow-ups
- **Client coordination:** `silo-android` / `silo-apple` keep working via the derived `role` field; follow-up tickets to surface group membership in account UI.
- **Revision-bump stampede:** bounded and acceptable (one re-resolve per active session); revisit with a cache only if profiling shows pressure.
- **Invite-code group overrides** and an expanded permission catalog (requests, watch-together, etc.) are natural follow-ups, out of scope here.
- **Plugin/API-key auth paths** (`internal/auth/api_key_repository.go`, jellycompat API keys) must be audited during implementation for any `role` reads not covered above.
@@ -1,65 +0,0 @@
# Home Notification Bars — Design
Date: 2026-06-11
Status: approved (design); implementation to follow
## Summary
Surface notifications at the top of the Home page so they're seen without opening the inbox tab, with system-level **announcements** visually distinguished from personal notifications. Two slim, dismissible bars stack at the top of Home: an **announcement bar** (distinct accent + 📢 + "Announcement" label) above a **notification strip** (the most recent unread personal notification). Both reuse existing hooks; no server changes. Web-only, additive.
## Goals
- Show the most recent unread notification high on the Home page, not only behind the bell/inbox.
- Make admin **announcements** clearly distinct from request/content/system/admin notifications, via a separate styled bar.
- Dismiss (✕) marks read and advances to the next unread; bars hide when nothing's unread.
- Reuse the Phase-2 notifications hooks; inherit per-profile + child filtering and live WebSocket updates for free.
## Non-goals
- No server/API changes (the data already distinguishes `category: "announcement"`).
- No change to the bell, inbox page, or toast (carrying the 📢 styling there is a possible follow-up, out of scope here).
- Not app-wide — Home page only.
## Components
Two components in `web/src/components/home/` (or alongside Home), each small and independently testable, plus one mount edit.
### AnnouncementBar (`web/src/components/AnnouncementBar.tsx`)
- Data: `useNotificationsList({ unread: true, category: "announcement" })` → the first (newest) item. Reliably finds the latest unread announcement regardless of how many other notifications are unread.
- Render: a distinct bar using the app's accent tokens (reads as a system notice, not a personal row) — a 📢 icon, an "ANNOUNCEMENT" label, the title, truncated body, relative time, and a ✕.
- Empty: renders nothing when there's no unread announcement.
- ✕: `useMarkRead().mutate({ ids: [id] })` → the unread query updates → the next unread announcement shows, or the bar hides. Marking read also drops the bell badge (shared cache).
- Click (anywhere but ✕): navigate to the notification's `link` (or `/notifications` when it has none) and mark it read.
### NotificationStrip (`web/src/components/NotificationStrip.tsx`)
- Data: `useNotificationsList({ unread: true })` → the first item whose `category !== "announcement"` (so announcements live only in the bar above, never duplicated here).
- Render: the slim strip — 🔔 icon, title, truncated body, relative time, ✕. Lighter styling than the announcement bar.
- Empty: renders nothing when there's no unread non-announcement.
- ✕ / click: same behavior as the bar (mark read + advance / navigate + mark read).
### Mount (`web/src/pages/Home.tsx`)
At the top of the Home content region (near the existing `heroSlot` / `TasteSeedBanner`), render `<AnnouncementBar />` then `<NotificationStrip />` as the first elements, so they sit above the content rows. When both are empty, Home renders exactly as today.
## Data flow & shared behavior
- Both bars read through TanStack Query; the announcement bar's query (`{unread:true, category:"announcement"}`) and the strip's (`{unread:true}`) are distinct cache keys — two small fetches, both already used elsewhere.
- Live updates: `useNotificationsLive` already invalidates the notifications list on `notification.created`, so both bars reactively show the newest unread without a reload.
- Filtering: the list endpoint already scopes to the active profile and suppresses child-restricted categories server-side, so the bars inherit correct per-profile behavior.
- The relative-time helper is the existing `timeAgo` (with the absolute-date fallback for old timestamps).
## Error handling
- While the query is loading, render nothing (no skeleton flash for a slim bar).
- A mark-read failure surfaces the existing mutation `onError` toast; the bar stays until the next successful read.
- A notification with no `link` navigates to `/notifications` rather than a dead link.
## Testing
- `AnnouncementBar.test.tsx`: renders the latest unread announcement with the 📢/label styling; ✕ calls `markRead({ids:[id]})`; clicking navigates (mocked `useNavigate`) to the link and marks read; renders nothing when no unread announcement.
- `NotificationStrip.test.tsx`: shows the newest unread non-announcement (skips an announcement at the head of the list); ✕ marks read; click navigates + marks read; renders nothing when no unread non-announcement.
- Existing suites stay green; tsc + lint clean.
## Future work
- Carry the 📢 announcement styling into the bell dropdown and inbox rows for a consistent identity.
- Optional: a per-user "hide the home bars" preference if they prove noisy.
+5 -11
View File
@@ -20,7 +20,6 @@ require (
)
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/abadojack/whatlanggo v1.0.1
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
@@ -31,25 +30,20 @@ require (
github.com/oklog/ulid/v2 v2.1.0
github.com/pgvector/pgvector-go v0.3.0
github.com/pressly/goose/v3 v3.27.1
github.com/sideshow/apns2 v0.25.0
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
@@ -62,7 +56,6 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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.10.0 // indirect
@@ -76,6 +69,7 @@ require (
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
github.com/zishang520/webtransport-go v0.9.1 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
)
@@ -104,10 +98,10 @@ require (
github.com/sony/sonyflake v1.3.0
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.21.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
google.golang.org/grpc v1.81.1
golang.org/x/time v0.14.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
+14 -97
View File
@@ -1,17 +1,11 @@
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
@@ -51,9 +45,8 @@ github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
@@ -76,15 +69,10 @@ github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -165,9 +153,8 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -198,13 +185,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=
github.com/sony/sonyflake v1.3.0 h1:tiB4Dlp0lnmKp/h6BLXA14P8Qi+LYS9+0QRpcrKHvg4=
github.com/sony/sonyflake v1.3.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -231,7 +215,6 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zishang520/engine.io-go-parser v1.3.2 h1:aEVrhQVhfk99Ct6htNffgHydUBC4dGclO/OXPz5CSy0=
@@ -250,10 +233,10 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -266,110 +249,44 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+5
View File
@@ -5,4 +5,9 @@ import "errors"
var (
ErrProfileNotFound = errors.New("profile not found")
ErrProfileUnverified = errors.New("profile unverified")
// ErrUserDisabled is returned when the viewer's account is disabled.
// Scope resolution re-loads the user on every request, so disabling an
// account denies content access on the next request for every surface
// that resolves viewer scope (native API and Jellyfin compat alike).
ErrUserDisabled = errors.New("user account disabled")
)
+14
View File
@@ -79,3 +79,17 @@ func MinQuality(a, b string) string {
return b
}
}
// MaxQuality returns the more permissive quality ceiling. An empty string
// means unrestricted and beats any concrete ceiling.
func MaxQuality(a, b string) string {
a = NormalizePlaybackQuality(a)
b = NormalizePlaybackQuality(b)
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
return ""
}
if CompareQuality(a, b) >= 0 {
return a
}
return b
}
+19
View File
@@ -54,3 +54,22 @@ func TestParsePlaybackQualityPreset(t *testing.T) {
t.Fatal("expected invalid preset to return ok=false")
}
}
func TestMaxQuality(t *testing.T) {
cases := []struct {
a, b, want string
}{
{"", "", ""},
{"", "1080p", ""}, // unrestricted beats any ceiling
{"2160p", "", ""},
{"1080p", "2160p", "2160p"},
{"2160p", "1080p", "2160p"},
{"1080p", "1080p", "1080p"},
{"720p", "480p", "1080p"}, // presets normalize: 720p/480p both -> 1080p (standard)
}
for _, tc := range cases {
if got := MaxQuality(tc.a, tc.b); got != tc.want {
t.Errorf("MaxQuality(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
}
}
}
+3
View File
@@ -46,6 +46,9 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
if err != nil {
return Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err)
}
if !user.Enabled {
return Scope{}, ErrUserDisabled
}
scope := Scope{
UserID: user.ID,
+20 -7
View File
@@ -256,7 +256,7 @@ func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int)
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -285,7 +285,7 @@ func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
nil,
)
@@ -304,7 +304,7 @@ func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -324,7 +324,7 @@ func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -344,7 +344,7 @@ func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[3,5]"},
@@ -367,7 +367,7 @@ func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[2,4]"},
@@ -388,9 +388,22 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
}
}
func TestResolver_DisabledUserIsDenied(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, Enabled: false, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
nil,
)
_, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if !errors.Is(err, ErrUserDisabled) {
t.Fatalf("Resolve() error = %v, want ErrUserDisabled", err)
}
}
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
settings: map[string]string{"disabled_library_ids": "[7]"},
}},
+8 -25
View File
@@ -16,7 +16,6 @@ import (
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
)
@@ -57,11 +56,8 @@ func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
// with embedded error object" responses are retried; other 4xx fail fast).
// The config is held behind an atomic pointer so admin settings changes apply
// to subsequent requests without rebuilding the client; each request snapshots
// the config once so its URL, key, and model stay consistent.
type Client struct {
cfg atomic.Pointer[Config]
cfg Config
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
// client-level timeout: a transcription upload's deadline is set per request
// (sized to the chunk duration), which a client timeout would silently cap.
@@ -71,23 +67,11 @@ type Client struct {
// NewClient builds a client from the endpoint config.
func NewClient(cfg Config) *Client {
c := &Client{
return &Client{
cfg: cfg,
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
asrHTTP: &http.Client{},
}
c.UpdateConfig(cfg)
return c
}
// UpdateConfig swaps the endpoint config. Safe for concurrent use; in-flight
// requests finish with the config they started with.
func (c *Client) UpdateConfig(cfg Config) {
c.cfg.Store(&cfg)
}
// Config returns a snapshot of the current endpoint config.
func (c *Client) Config() Config {
return *c.cfg.Load()
}
// Message is one chat-completions message.
@@ -123,9 +107,8 @@ type chatCompletionResponse struct {
// When jsonObject is true it requests response_format=json_object; providers
// that ignore the field still work because the prompt itself demands JSON.
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
cfg := c.Config()
reqBody := chatCompletionRequest{
Model: cfg.ChatModel,
Model: c.cfg.ChatModel,
Messages: messages,
Temperature: 0.2,
}
@@ -138,7 +121,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool)
return "", fmt.Errorf("marshal chat request: %w", err)
}
url := endpointURL(cfg.BaseURL, "chat/completions")
url := endpointURL(c.cfg.BaseURL, "chat/completions")
var content string
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
@@ -148,8 +131,8 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool)
return nil, fmt.Errorf("create request: %w", reqErr)
}
httpReq.Header.Set("Content-Type", "application/json")
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
if c.cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
}
return httpReq, nil
},
@@ -164,7 +147,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool)
}
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
slog.Warn("AI chat returned no choices, retrying",
"model", cfg.ChatModel, "response_bytes", len(respBody))
"model", c.cfg.ChatModel, "response_bytes", len(respBody))
return fmt.Errorf("chat API returned no choices")
}
content = parsed.Choices[0].Message.Content
+8 -9
View File
@@ -142,13 +142,12 @@ func hostMatchesAny(baseURL string, hosts []string) bool {
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
// response_format=verbose_json for segment timestamps.
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
cfg := c.Config()
if !cfg.ASRConfigured() {
if !c.cfg.ASRConfigured() {
return nil, fmt.Errorf("transcription endpoint is not configured")
}
if IsChatOnlyGateway(cfg.asrBaseURL()) {
if IsChatOnlyGateway(c.cfg.asrBaseURL()) {
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", cfg.asrBaseURL())
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", c.cfg.asrBaseURL())
}
if len(req.Audio) == 0 {
return nil, fmt.Errorf("no audio data to transcribe")
@@ -161,7 +160,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := endpointURL(cfg.asrBaseURL(), "audio/transcriptions")
url := endpointURL(c.cfg.asrBaseURL(), "audio/transcriptions")
var result *Transcription
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
@@ -176,7 +175,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
return nil, fmt.Errorf("write multipart audio: %w", err)
}
fields := [][2]string{
{"model", cfg.ASRModel},
{"model", c.cfg.ASRModel},
{"response_format", "verbose_json"},
{"temperature", "0"},
// Word timings let cue building split paragraph-length
@@ -188,7 +187,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
if req.Language != "" {
fields = append(fields, [2]string{"language", req.Language})
}
if !hostMatchesAny(cfg.asrBaseURL(), strictHostedASRHosts) {
if !hostMatchesAny(c.cfg.asrBaseURL(), strictHostedASRHosts) {
fields = append(fields, [2]string{"vad_filter", "true"})
}
for _, f := range fields {
@@ -205,7 +204,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", w.FormDataContentType())
if key := cfg.asrAPIKey(); key != "" {
if key := c.cfg.asrAPIKey(); key != "" {
httpReq.Header.Set("Authorization", "Bearer "+key)
}
return httpReq, nil
@@ -220,7 +219,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
}
if parsed.Segments == nil {
return &permanentError{err: fmt.Errorf(
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", cfg.ASRModel)}
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", c.cfg.ASRModel)}
}
out := &Transcription{Language: parsed.Language}
for _, s := range *parsed.Segments {
+107 -223
View File
@@ -1,7 +1,6 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -27,8 +26,6 @@ import (
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
@@ -105,11 +102,17 @@ func NewAdminHandler(
pool *pgxpool.Pool,
storeProv userstore.UserStoreProvider,
) *AdminHandler {
var groupResolver auth.GroupResolver
if pool != nil {
groupResolver = auth.NewGroupRepository(pool)
}
return &AdminHandler{
userRepo: userRepo,
pool: pool,
storeProv: storeProv,
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
userRepo: userRepo,
pool: pool,
storeProv: storeProv,
// No settings dependency at construction time: nil settings makes the
// provisioner fall back to the built-in users group for defaults.
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv, nil, groupResolver),
}
}
@@ -117,115 +120,53 @@ func NewAdminHandler(
// createUserRequest represents the JSON body for POST /admin/users.
type createUserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Permissions createStringSliceField `json:"permissions"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
type createStringSliceField struct {
Set bool
Value []string
}
func (f *createStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
type updateLibraryIDsField struct {
Set bool
Value []int
}
func (f *updateLibraryIDsField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = nil
return nil
}
return json.Unmarshal(data, &f.Value)
}
func (f updateLibraryIDsField) Ptr() *[]int {
if !f.Set {
return nil
}
value := append([]int(nil), f.Value...)
return &value
}
type updateStringSliceField struct {
Set bool
Value []string
}
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
func (f updateStringSliceField) Ptr() *[]string {
if !f.Set {
return nil
}
value := append([]string(nil), f.Value...)
return &value
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
// GroupIDs are the group memberships to assign. Omitted (nil) means the
// configured default groups are used.
GroupIDs []int `json:"group_ids"`
}
// updateUserRequest represents the JSON body for PUT /admin/users/{id}.
type updateUserRequest struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Role *string `json:"role,omitempty"`
Permissions updateStringSliceField `json:"permissions,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
// GroupIDs replaces the user's group memberships. Omitted = unchanged.
GroupIDs *[]int `json:"group_ids,omitempty"`
}
// adminUserResponse represents a user in admin JSON responses.
// adminGroupRef is a lightweight group reference in admin user responses.
type adminGroupRef struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
// adminUserResponse represents a user in admin JSON responses. The policy
// fields are the effective values derived from the user's groups.
type adminUserResponse struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
Groups []adminGroupRef `json:"groups"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
}
type adminPlaybackHistoryRow struct {
@@ -271,15 +212,21 @@ func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
return ""
}
// toAdminUserResponse converts a User model to an admin API response.
// toAdminUserResponse converts a User model (with its group memberships
// loaded into u.Groups at load time) to an admin API response.
func toAdminUserResponse(u *models.User) adminUserResponse {
refs := make([]adminGroupRef, 0, len(u.Groups))
for _, g := range u.Groups {
refs = append(refs, adminGroupRef{ID: g.ID, Slug: g.Slug, Name: g.Name})
}
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role,
Role: auth.RoleForUser(u),
Permissions: append([]string{}, u.Permissions...),
Enabled: u.Enabled,
Groups: refs,
LibraryIDs: append([]int(nil), u.LibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
MaxStreams: u.MaxStreams,
@@ -342,10 +289,13 @@ func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
return
}
resp := make([]adminUserResponse, 0, len(users))
userIDs := make([]int, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
resp := make([]adminUserResponse, 0, len(users))
for _, u := range users {
resp = append(resp, toAdminUserResponse(u))
}
lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs)
@@ -395,44 +345,17 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
req.Username = auth.NormalizeUsername(req.Username)
req.Email = auth.NormalizeEmail(req.Email)
if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required")
return
}
maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
permissions := auth.DefaultUserPermissions()
if req.Permissions.Set {
permissions = req.Permissions.Value
}
permissions, err := auth.NormalizePermissions(permissions)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
if req.Username == "" || req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
return
}
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
User: models.CreateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
Username: req.Username,
Email: req.Email,
Password: req.Password,
GroupIDs: req.GroupIDs,
},
DefaultProfile: auth.DefaultProfileOptions{
Enabled: req.CreateDefaultProfile,
@@ -440,6 +363,10 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
},
})
if err != nil {
if errors.Is(err, auth.ErrUnknownGroup) {
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
return
}
@@ -463,43 +390,12 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
return
}
var maxPlaybackQuality *string
if req.MaxPlaybackQuality != nil {
normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
maxPlaybackQuality = &normalized
}
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
var permissions *[]string
if req.Permissions.Set {
normalized, err := auth.NormalizePermissions(req.Permissions.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
permissions = &normalized
}
updateInput := models.UpdateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
Enabled: req.Enabled,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
Username: req.Username,
Email: req.Email,
Password: req.Password,
Enabled: req.Enabled,
GroupIDs: req.GroupIDs,
}
var currentUser *models.User
@@ -521,6 +417,14 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
if errors.Is(err, auth.ErrLastAdministrator) {
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
return
}
if errors.Is(err, auth.ErrUnknownGroup) {
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
return
}
@@ -531,15 +435,6 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
}
}
// Publish a domain event rather than creating the notification inline; the
// notifications materializer's system matcher turns it into the user's
// "Password changed" notice, keeping all notification creation event-driven.
if req.Password != nil && h.RealtimeHub != nil {
_ = h.RealtimeHub.EventsHub().PublishJSON(r.Context(),
evt.ChannelSessions, notifications.EventUserPasswordChanged, nil,
evt.PublishOptions{UserID: id})
}
user, err := h.userRepo.GetByID(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated user")
@@ -560,6 +455,10 @@ func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request)
err = h.userRepo.Delete(r.Context(), id)
if err != nil {
if errors.Is(err, auth.ErrLastAdministrator) {
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete user")
return
}
@@ -803,10 +702,8 @@ func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Req
func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool {
return input.Password != nil ||
input.Role != nil ||
input.Enabled != nil ||
input.Permissions != nil ||
input.MaxPlaybackQuality != nil
input.GroupIDs != nil
}
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
@@ -816,22 +713,28 @@ func updateRequiresSessionRevocation(current *models.User, input models.UpdateUs
if current == nil {
return updateMayRequireSessionRevocation(input)
}
if input.Role != nil && *input.Role != current.Role {
return true
}
if input.Enabled != nil && *input.Enabled != current.Enabled {
return true
}
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
return true
}
if input.MaxPlaybackQuality != nil &&
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
// Group membership changes alter the effective policy (permissions,
// quality ceilings, ...), so a real change revokes sessions.
if input.GroupIDs != nil && !equalIntSets(*input.GroupIDs, current.GroupIDs) {
return true
}
return false
}
// equalIntSets reports whether two int slices contain the same set of values,
// ignoring order and duplicates.
func equalIntSets(a, b []int) bool {
normalize := func(values []int) []int {
out := append([]int(nil), values...)
sort.Ints(out)
return slices.Compact(out)
}
return slices.Equal(normalize(a), normalize(b))
}
func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error {
if h.pool == nil {
return nil
@@ -1032,7 +935,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
}
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
writeJSON(w, http.StatusAccepted, adminJobToResponseForViewer(r, job, nil, isAdminRequest(r, h.userRepo)))
}
// UpdateItemMetadataRequest contains the fields that can be updated via
@@ -1228,9 +1131,6 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R
type adminSettingResponse struct {
Key string `json:"key"`
Value string `json:"value"`
// RestartRequired reports whether the saved value only takes effect
// after a server restart (set on update responses only).
RestartRequired bool `json:"restart_required,omitempty"`
}
type adminSettingsListResponse struct {
@@ -2094,21 +1994,6 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
return
}
if key == "defaults.max_playback_quality" {
normalized, ok := access.ParsePlaybackQualityPreset(req.Value)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
req.Value = normalized
}
if key == "defaults.max_profiles" {
n, err := strconv.Atoi(req.Value)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "defaults.max_profiles must be an integer >= 1")
return
}
}
switch key {
case markers.SettingMode, markers.SettingLazyPlayback:
if normalized, err := markers.NormalizeSetting(key, req.Value); err != nil {
@@ -2159,10 +2044,9 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
h.OnServerSettingUpdated(r.Context(), key, req.Value)
}
restartRequired := config.RestartRequired(key)
if sensitiveSettingKeys[key] {
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key})
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
}
+29
View File
@@ -0,0 +1,29 @@
package handlers
import (
"net/http"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
// isAdminRequest reports whether the request's authenticated user currently
// holds the admin permission. Admin authority is always derived from the
// freshly loaded user (group-derived policy), never from token contents.
// It first consults the user stashed by the RequireAdmin middleware and
// falls back to loading via the given loader.
func isAdminRequest(r *http.Request, loader auth.UserLoader) bool {
ctx := r.Context()
if user := apimw.GetUser(ctx); user != nil {
return user.Enabled && user.IsAdmin
}
claims := apimw.GetClaims(ctx)
if claims == nil || loader == nil {
return false
}
user, err := loader.GetByID(ctx, claims.UserID)
if err != nil || user == nil {
return false
}
return user.Enabled && user.IsAdmin
}
@@ -27,7 +27,6 @@ func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, reader)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "admin",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
+379
View File
@@ -0,0 +1,379 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// GroupStore defines the group repository operations the AdminGroupsHandler
// needs, so tests can substitute a fake.
type GroupStore interface {
List(ctx context.Context) ([]auth.GroupWithMemberCount, error)
GetByID(ctx context.Context, id int) (*models.Group, error)
MemberCount(ctx context.Context, groupID int) (int, error)
Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error)
Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error)
Delete(ctx context.Context, id int) error
ListMembers(ctx context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error)
AddMember(ctx context.Context, groupID, userID int) error
RemoveMember(ctx context.Context, groupID, userID int) error
}
// AdminGroupsHandler handles admin-only group management endpoints.
type AdminGroupsHandler struct {
store GroupStore
}
// NewAdminGroupsHandler creates an AdminGroupsHandler backed by the given
// group store.
func NewAdminGroupsHandler(store GroupStore) *AdminGroupsHandler {
return &AdminGroupsHandler{store: store}
}
// --- Request/Response types ---
// createAdminGroupRequest represents the JSON body for POST /admin/groups.
type createAdminGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
LibraryIDs []int `json:"library_ids"` // null/absent = all libraries
MaxStreams *int `json:"max_streams"`
MaxTranscodes *int `json:"max_transcodes"`
MaxProfiles *int `json:"max_profiles"`
MaxPlaybackQuality *string `json:"max_playback_quality"`
DownloadAllowed *bool `json:"download_allowed"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed"`
}
// updateAdminGroupRequest represents the JSON body for PATCH /admin/groups/{id}.
// Absent fields are left unchanged.
type updateAdminGroupRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Permissions optionalStringSliceField `json:"permissions,omitempty"`
LibraryIDs optionalIntSliceField `json:"library_ids,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
// adminGroupResponse represents a group in admin JSON responses.
type adminGroupResponse struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Permissions []string `json:"permissions"`
LibraryIDs []int `json:"library_ids"` // null = all libraries
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
MaxPlaybackQuality string `json:"max_playback_quality"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type adminGroupListResponse struct {
Groups []adminGroupResponse `json:"groups"`
}
type adminGroupMemberRow struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Enabled bool `json:"enabled"`
}
type adminGroupMembersResponse struct {
Members []adminGroupMemberRow `json:"members"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func toAdminGroupResponse(g *models.Group, memberCount int) adminGroupResponse {
return adminGroupResponse{
ID: g.ID,
Slug: g.Slug,
Name: g.Name,
Description: g.Description,
BuiltIn: g.BuiltIn,
Permissions: append([]string{}, g.Permissions...),
LibraryIDs: g.LibraryIDs,
MaxStreams: g.MaxStreams,
MaxTranscodes: g.MaxTranscodes,
MaxProfiles: g.MaxProfiles,
MaxPlaybackQuality: g.MaxPlaybackQuality,
DownloadAllowed: g.DownloadAllowed,
DownloadTranscodeAllowed: g.DownloadTranscodeAllowed,
MemberCount: memberCount,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
}
}
// --- Helpers ---
// writeGroupError maps group repository sentinel errors to API responses.
func writeGroupError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, auth.ErrGroupNotFound):
writeError(w, http.StatusNotFound, "not_found", "Group not found")
case errors.Is(err, auth.ErrUnknownUser):
writeError(w, http.StatusNotFound, "unknown_user", "User not found")
case errors.Is(err, auth.ErrBuiltInGroup):
writeError(w, http.StatusConflict, "built_in_group", "Built-in groups cannot be deleted")
case errors.Is(err, auth.ErrAdminPermRequired):
writeError(w, http.StatusConflict, "admin_permission_required", "The administrators group must keep the admin permission")
case errors.Is(err, auth.ErrLastAdministrator):
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
case errors.Is(err, auth.ErrDuplicate):
writeError(w, http.StatusConflict, "duplicate", "A group with that name already exists")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Group operation failed")
}
}
func parseGroupIDParam(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
id, err := strconv.Atoi(chi.URLParam(r, name))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+name)
return 0, false
}
return id, true
}
// --- Handler methods ---
// HandleList handles GET /admin/groups.
func (h *AdminGroupsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
groups, err := h.store.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list groups")
return
}
resp := adminGroupListResponse{Groups: make([]adminGroupResponse, 0, len(groups))}
for i := range groups {
resp.Groups = append(resp.Groups, toAdminGroupResponse(&groups[i].Group, groups[i].MemberCount))
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreate handles POST /admin/groups.
func (h *AdminGroupsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
var req createAdminGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Group name is required")
return
}
permissions, err := auth.NormalizePermissions(req.Permissions)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
group, err := h.store.Create(r.Context(), models.CreateGroupInput{
Name: name,
Description: req.Description,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
MaxPlaybackQuality: req.MaxPlaybackQuality,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
})
if err != nil {
writeGroupError(w, err)
return
}
writeJSON(w, http.StatusCreated, toAdminGroupResponse(group, 0))
}
// HandleGet handles GET /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
group, err := h.store.GetByID(r.Context(), id)
if err != nil {
writeGroupError(w, err)
return
}
memberCount, err := h.store.MemberCount(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
return
}
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
}
// HandleUpdate handles PATCH /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
var req updateAdminGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Group name cannot be empty")
return
}
input := models.UpdateGroupInput{
Name: req.Name,
Description: req.Description,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
MaxPlaybackQuality: req.MaxPlaybackQuality,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
}
if req.Permissions.Set {
permissions, err := auth.NormalizePermissions(req.Permissions.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
input.Permissions = &permissions
}
group, err := h.store.Update(r.Context(), id, input)
if err != nil {
writeGroupError(w, err)
return
}
memberCount, err := h.store.MemberCount(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
return
}
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
}
// HandleDelete handles DELETE /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
if err := h.store.Delete(r.Context(), id); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListMembers handles GET /admin/groups/{id}/members.
func (h *AdminGroupsHandler) HandleListMembers(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
if _, err := h.store.GetByID(r.Context(), id); err != nil {
writeGroupError(w, err)
return
}
q := r.URL.Query()
offset := 0
if v := q.Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
members, total, err := h.store.ListMembers(r.Context(), id, offset, limit)
if err != nil {
writeGroupError(w, err)
return
}
resp := adminGroupMembersResponse{
Members: make([]adminGroupMemberRow, 0, len(members)),
Total: total,
Offset: offset,
Limit: limit,
}
for _, m := range members {
resp.Members = append(resp.Members, adminGroupMemberRow{
UserID: m.UserID,
Username: m.Username,
Email: m.Email,
Enabled: m.Enabled,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleAddMember handles PUT /admin/groups/{id}/members/{userID}.
func (h *AdminGroupsHandler) HandleAddMember(w http.ResponseWriter, r *http.Request) {
groupID, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
userID, ok := parseGroupIDParam(w, r, "userID")
if !ok {
return
}
if err := h.store.AddMember(r.Context(), groupID, userID); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRemoveMember handles DELETE /admin/groups/{id}/members/{userID}.
func (h *AdminGroupsHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
groupID, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
userID, ok := parseGroupIDParam(w, r, "userID")
if !ok {
return
}
if err := h.store.RemoveMember(r.Context(), groupID, userID); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+550
View File
@@ -0,0 +1,550 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeGroupStore is a configurable in-memory GroupStore that records the
// inputs handlers pass to it.
type fakeGroupStore struct {
listGroups []auth.GroupWithMemberCount
listErr error
getGroup *models.Group
getErr error
memberCount int
countErr error
createInput *models.CreateGroupInput
createGroup *models.Group
createErr error
updateID int
updateInput *models.UpdateGroupInput
updateGroup *models.Group
updateErr error
deleteID int
deleteErr error
membersGroupID int
membersOffset int
membersLimit int
members []auth.GroupMember
membersTotal int
membersErr error
addGroupID int
addUserID int
addErr error
removeGroupID int
removeUserID int
removeErr error
}
func (f *fakeGroupStore) List(context.Context) ([]auth.GroupWithMemberCount, error) {
return f.listGroups, f.listErr
}
func (f *fakeGroupStore) GetByID(_ context.Context, id int) (*models.Group, error) {
return f.getGroup, f.getErr
}
func (f *fakeGroupStore) MemberCount(_ context.Context, groupID int) (int, error) {
return f.memberCount, f.countErr
}
func (f *fakeGroupStore) Create(_ context.Context, input models.CreateGroupInput) (*models.Group, error) {
f.createInput = &input
return f.createGroup, f.createErr
}
func (f *fakeGroupStore) Update(_ context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
f.updateID = id
f.updateInput = &input
return f.updateGroup, f.updateErr
}
func (f *fakeGroupStore) Delete(_ context.Context, id int) error {
f.deleteID = id
return f.deleteErr
}
func (f *fakeGroupStore) ListMembers(_ context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error) {
f.membersGroupID = groupID
f.membersOffset = offset
f.membersLimit = limit
return f.members, f.membersTotal, f.membersErr
}
func (f *fakeGroupStore) AddMember(_ context.Context, groupID, userID int) error {
f.addGroupID = groupID
f.addUserID = userID
return f.addErr
}
func (f *fakeGroupStore) RemoveMember(_ context.Context, groupID, userID int) error {
f.removeGroupID = groupID
f.removeUserID = userID
return f.removeErr
}
// newGroupsTestRouter mounts the handler routes the same way the API router
// does so tests exercise real chi URL params.
func newGroupsTestRouter(store *fakeGroupStore) chi.Router {
h := NewAdminGroupsHandler(store)
r := chi.NewRouter()
r.Get("/groups", h.HandleList)
r.Post("/groups", h.HandleCreate)
r.Get("/groups/{id}", h.HandleGet)
r.Patch("/groups/{id}", h.HandleUpdate)
r.Delete("/groups/{id}", h.HandleDelete)
r.Get("/groups/{id}/members", h.HandleListMembers)
r.Put("/groups/{id}/members/{userID}", h.HandleAddMember)
r.Delete("/groups/{id}/members/{userID}", h.HandleRemoveMember)
return r
}
func doGroupsRequest(t *testing.T, router chi.Router, method, target string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshaling request body: %v", err)
}
reader = bytes.NewReader(payload)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, target, reader)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func decodeGroupsJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decoding response %q: %v", rec.Body.String(), err)
}
return out
}
func assertGroupsError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if rec.Code != status {
t.Fatalf("status = %d, want %d (body %q)", rec.Code, status, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
if body["error"] != code {
t.Fatalf("error code = %v, want %q (body %q)", body["error"], code, rec.Body.String())
}
}
func testGroup() *models.Group {
return &models.Group{
ID: 7,
Slug: "film-club",
Name: "Film Club",
Description: "Movie nights",
Permissions: []string{"marker_edit"},
LibraryIDs: nil,
MaxStreams: 2,
MaxTranscodes: 1,
MaxProfiles: 4,
MaxPlaybackQuality: "1080p",
CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
}
func TestAdminGroupsList(t *testing.T) {
store := &fakeGroupStore{
listGroups: []auth.GroupWithMemberCount{
{Group: models.Group{ID: 1, Slug: "administrators", Name: "Administrators", BuiltIn: true, Permissions: []string{"admin"}}, MemberCount: 1},
{Group: models.Group{ID: 2, Slug: "users", Name: "Users", BuiltIn: true, LibraryIDs: []int{3}}, MemberCount: 12},
},
}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
groups, ok := body["groups"].([]any)
if !ok || len(groups) != 2 {
t.Fatalf("groups = %v, want 2 entries", body["groups"])
}
first := groups[0].(map[string]any)
if first["member_count"] != float64(1) {
t.Fatalf("member_count = %v, want 1", first["member_count"])
}
if first["library_ids"] != nil {
t.Fatalf("library_ids = %v, want null for all-libraries", first["library_ids"])
}
second := groups[1].(map[string]any)
if second["member_count"] != float64(12) {
t.Fatalf("member_count = %v, want 12", second["member_count"])
}
if ids, ok := second["library_ids"].([]any); !ok || len(ids) != 1 || ids[0] != float64(3) {
t.Fatalf("library_ids = %v, want [3]", second["library_ids"])
}
}
func TestAdminGroupsCreate(t *testing.T) {
store := &fakeGroupStore{createGroup: testGroup()}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": " Film Club ",
"description": "Movie nights",
"permissions": []string{"marker_edit"},
"library_ids": []int{1, 2},
"max_streams": 2,
})
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body %q)", rec.Code, rec.Body.String())
}
if store.createInput == nil {
t.Fatal("store.Create was not called")
}
if store.createInput.Name != "Film Club" {
t.Fatalf("Name = %q, want trimmed name", store.createInput.Name)
}
if len(store.createInput.LibraryIDs) != 2 {
t.Fatalf("LibraryIDs = %v, want [1 2]", store.createInput.LibraryIDs)
}
if store.createInput.MaxStreams == nil || *store.createInput.MaxStreams != 2 {
t.Fatalf("MaxStreams = %v, want 2", store.createInput.MaxStreams)
}
if store.createInput.MaxTranscodes != nil {
t.Fatalf("MaxTranscodes = %v, want nil (omitted)", store.createInput.MaxTranscodes)
}
body := decodeGroupsJSON(t, rec)
if body["slug"] != "film-club" {
t.Fatalf("slug = %v, want film-club", body["slug"])
}
if body["member_count"] != float64(0) {
t.Fatalf("member_count = %v, want 0 for new group", body["member_count"])
}
}
func TestAdminGroupsCreateEmptyName(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": " ",
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.createInput != nil {
t.Fatal("store.Create should not be called for empty name")
}
}
func TestAdminGroupsCreateUnknownPermission(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": "Film Club",
"permissions": []string{"server_owner"},
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.createInput != nil {
t.Fatal("store.Create should not be called for unknown permission")
}
}
func TestAdminGroupsCreateDuplicate(t *testing.T) {
store := &fakeGroupStore{createErr: auth.ErrDuplicate}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": "Film Club",
})
assertGroupsError(t, rec, http.StatusConflict, "duplicate")
}
func TestAdminGroupsGet(t *testing.T) {
store := &fakeGroupStore{getGroup: testGroup(), memberCount: 5}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
if body["id"] != float64(7) {
t.Fatalf("id = %v, want 7", body["id"])
}
if body["member_count"] != float64(5) {
t.Fatalf("member_count = %v, want 5", body["member_count"])
}
}
func TestAdminGroupsGetNotFound(t *testing.T) {
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999", nil)
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
}
func TestAdminGroupsGetInvalidID(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/abc", nil)
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
}
func TestAdminGroupsPatchStripAdminPermission(t *testing.T) {
store := &fakeGroupStore{updateErr: auth.ErrAdminPermRequired}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/1", map[string]any{
"permissions": []string{"marker_edit"},
})
assertGroupsError(t, rec, http.StatusConflict, "admin_permission_required")
}
func TestAdminGroupsPatchUnknownPermission(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/7", map[string]any{
"permissions": []string{"server_owner"},
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.updateInput != nil {
t.Fatal("store.Update should not be called for unknown permission")
}
}
func TestAdminGroupsPatchLibraryIDsSemantics(t *testing.T) {
tests := []struct {
name string
body string
want func(t *testing.T, input *models.UpdateGroupInput)
}{
{
name: "absent means no change",
body: `{"name":"Renamed"}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs != nil {
t.Fatalf("LibraryIDs = %v, want nil (no change)", input.LibraryIDs)
}
},
},
{
name: "null means all libraries",
body: `{"library_ids":null}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to nil slice (all libraries)")
}
if *input.LibraryIDs != nil {
t.Fatalf("*LibraryIDs = %v, want nil slice (all libraries)", *input.LibraryIDs)
}
},
},
{
name: "empty array means no libraries",
body: `{"library_ids":[]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to empty slice (no libraries)")
}
if *input.LibraryIDs == nil || len(*input.LibraryIDs) != 0 {
t.Fatalf("*LibraryIDs = %v, want non-nil empty slice", *input.LibraryIDs)
}
},
},
{
name: "explicit ids",
body: `{"library_ids":[1,2]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to [1 2]")
}
ids := *input.LibraryIDs
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("*LibraryIDs = %v, want [1 2]", ids)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeGroupStore{updateGroup: testGroup()}
router := newGroupsTestRouter(store)
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.updateInput == nil {
t.Fatal("store.Update was not called")
}
tt.want(t, store.updateInput)
})
}
}
func TestAdminGroupsDeleteBuiltIn(t *testing.T) {
store := &fakeGroupStore{deleteErr: auth.ErrBuiltInGroup}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1", nil)
assertGroupsError(t, rec, http.StatusConflict, "built_in_group")
}
func TestAdminGroupsDelete(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.deleteID != 7 {
t.Fatalf("deleted id = %d, want 7", store.deleteID)
}
}
func TestAdminGroupsListMembersPagination(t *testing.T) {
store := &fakeGroupStore{
members: []auth.GroupMember{
{UserID: 3, Username: "alice", Email: "alice@example.com", Enabled: true},
},
membersTotal: 42,
}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members?offset=10&limit=5", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.membersGroupID != 7 || store.membersOffset != 10 || store.membersLimit != 5 {
t.Fatalf("ListMembers(%d, %d, %d), want (7, 10, 5)",
store.membersGroupID, store.membersOffset, store.membersLimit)
}
body := decodeGroupsJSON(t, rec)
if body["total"] != float64(42) || body["offset"] != float64(10) || body["limit"] != float64(5) {
t.Fatalf("pagination = total %v offset %v limit %v, want 42/10/5",
body["total"], body["offset"], body["limit"])
}
members, ok := body["members"].([]any)
if !ok || len(members) != 1 {
t.Fatalf("members = %v, want 1 entry", body["members"])
}
member := members[0].(map[string]any)
if member["user_id"] != float64(3) || member["username"] != "alice" ||
member["email"] != "alice@example.com" || member["enabled"] != true {
t.Fatalf("member = %v, want alice row", member)
}
}
func TestAdminGroupsListMembersDefaults(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.membersOffset != 0 || store.membersLimit != 50 {
t.Fatalf("ListMembers offset %d limit %d, want defaults 0/50",
store.membersOffset, store.membersLimit)
}
body := decodeGroupsJSON(t, rec)
if members, ok := body["members"].([]any); !ok || len(members) != 0 {
t.Fatalf("members = %v, want empty array (not null)", body["members"])
}
}
func TestAdminGroupsAddMember(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/12", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.addGroupID != 7 || store.addUserID != 12 {
t.Fatalf("AddMember(%d, %d), want (7, 12)", store.addGroupID, store.addUserID)
}
}
func TestAdminGroupsRemoveLastAdministrator(t *testing.T) {
store := &fakeGroupStore{removeErr: auth.ErrLastAdministrator}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1/members/12", nil)
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestAdminGroupsRemoveMember(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7/members/12", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.removeGroupID != 7 || store.removeUserID != 12 {
t.Fatalf("RemoveMember(%d, %d), want (7, 12)", store.removeGroupID, store.removeUserID)
}
}
func TestAdminGroupsAddMemberUnknownUser(t *testing.T) {
store := &fakeGroupStore{addErr: auth.ErrUnknownUser}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/999", nil)
assertGroupsError(t, rec, http.StatusNotFound, "unknown_user")
}
func TestAdminGroupsListMembersNotFound(t *testing.T) {
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999/members", nil)
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
}
func TestAdminGroupsPatchPermissionsSemantics(t *testing.T) {
tests := []struct {
name string
body string
want func(t *testing.T, input *models.UpdateGroupInput)
}{
{
name: "explicit permissions reach store as pointer to slice",
body: `{"permissions":["marker_edit"]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions == nil {
t.Fatal("Permissions = nil, want pointer to [\"marker_edit\"]")
}
perms := *input.Permissions
if len(perms) != 1 || perms[0] != "marker_edit" {
t.Fatalf("*Permissions = %v, want [\"marker_edit\"]", perms)
}
},
},
{
name: "null permissions reaches store as pointer to empty slice",
body: `{"permissions":null}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions == nil {
t.Fatal("Permissions = nil, want pointer to empty slice")
}
if len(*input.Permissions) != 0 {
t.Fatalf("*Permissions = %v, want empty slice", *input.Permissions)
}
},
},
{
name: "absent permissions reaches store as nil (no change)",
body: `{"name":"Renamed"}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions != nil {
t.Fatalf("Permissions = %v, want nil (no change)", input.Permissions)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeGroupStore{updateGroup: testGroup()}
router := newGroupsTestRouter(store)
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.updateInput == nil {
t.Fatal("store.Update was not called")
}
tt.want(t, store.updateInput)
})
}
}
+14 -12
View File
@@ -37,6 +37,7 @@ type AdminJobsHandler struct {
store AdminJobArtifactStore
CancelRegistry *adminjob.CancelRegistry
RealtimeHub *notifications.Hub
Users auth.UserLoader // for server-side admin checks on non-admin-gated routes
}
func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler {
@@ -122,13 +123,14 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
return
}
claims := apimw.GetClaims(r.Context())
if !canReadAdminJob(claims, job) {
viewerUserID := apimw.GetUserID(r.Context())
viewerIsAdmin := isAdminRequest(r, h.Users)
if !canReadAdminJob(viewerUserID, viewerIsAdmin, job) {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
writeJSON(w, http.StatusOK, adminJobToResponseForViewer(r, job, h.store, viewerIsAdmin))
}
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
@@ -264,19 +266,19 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
return resp
}
func adminJobToResponseForClaims(
func adminJobToResponseForViewer(
r *http.Request,
job *models.AdminJob,
store AdminJobArtifactStore,
claims *auth.Claims,
viewerIsAdmin bool,
) adminJobResponse {
response := adminJobToResponse(r, job, store)
sanitizeAdminJobResponseForClaims(&response, claims)
sanitizeAdminJobResponseForViewer(&response, viewerIsAdmin)
return response
}
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
if response == nil || (claims != nil && claims.Role == "admin") {
func sanitizeAdminJobResponseForViewer(response *adminJobResponse, viewerIsAdmin bool) {
if response == nil || viewerIsAdmin {
return
}
response.RequestPayload = json.RawMessage(`{}`)
@@ -384,12 +386,12 @@ func currentAdminUserID(r *http.Request) int {
return claims.UserID
}
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
if claims == nil || job == nil {
func canReadAdminJob(viewerUserID int, viewerIsAdmin bool, job *models.AdminJob) bool {
if viewerUserID == 0 || job == nil {
return false
}
if claims.Role == "admin" {
if viewerIsAdmin {
return true
}
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == viewerUserID
}
+6 -12
View File
@@ -13,46 +13,40 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
)
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
claims := &auth.Claims{UserID: 1, Role: "admin"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
if !canReadAdminJob(claims, job) {
if !canReadAdminJob(1, true, job) {
t.Fatal("admin should be allowed to read any job")
}
}
func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
if !canReadAdminJob(claims, job) {
if !canReadAdminJob(2, false, job) {
t.Fatal("creator should be allowed to read own item refresh job")
}
}
func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
if canReadAdminJob(claims, job) {
if canReadAdminJob(2, false, job) {
t.Fatal("non-admin should not read non-item-refresh jobs")
}
}
func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 3, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
if canReadAdminJob(claims, job) {
if canReadAdminJob(3, false, job) {
t.Fatal("non-admin should not read another user's item refresh job")
}
}
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
job := &models.AdminJob{
JobType: adminjob.JobTypeItemRefresh,
CreatedByUserID: 2,
@@ -66,7 +60,7 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes
PublicURL: "https://example.test/public",
}
resp := adminJobToResponseForClaims(nil, job, nil, claims)
resp := adminJobToResponseForViewer(nil, job, nil, false)
if string(resp.RequestPayload) != `{}` {
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
@@ -1,143 +0,0 @@
package handlers
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
)
// ---------------------------------------------------------------------------
// Minimal fake UserRepository for AdminHandler tests.
// ---------------------------------------------------------------------------
type fakeUserRepo struct {
user *models.User
getErr error
}
func (f *fakeUserRepo) List(_ context.Context) ([]*models.User, error) { return nil, nil }
func (f *fakeUserRepo) Create(_ context.Context, _ models.CreateUserInput) (*models.User, error) {
return f.user, nil
}
func (f *fakeUserRepo) Update(_ context.Context, _ int, _ models.UpdateUserInput) error {
return nil
}
func (f *fakeUserRepo) Delete(_ context.Context, _ int) error { return nil }
func (f *fakeUserRepo) GetByID(_ context.Context, _ int) (*models.User, error) {
return f.user, f.getErr
}
// Compile-time check.
var _ UserRepository = (*fakeUserRepo)(nil)
// ---------------------------------------------------------------------------
// Helper: build AdminHandler with a realtime hub whose events the test can
// observe. Password changes publish a domain event; the notifications
// materializer (tested separately) turns it into the user's notification.
// ---------------------------------------------------------------------------
func newAdminHandlerWithHub(userRepo UserRepository) (*AdminHandler, *evt.Hub) {
rh := notifications.NewHub("test", nil)
h := NewAdminHandler(userRepo, nil, nil)
h.RealtimeHub = rh
return h, rh.EventsHub()
}
// adminUpdateRequest builds a PUT /admin/users/{id} HTTP request.
func adminUpdateRequest(body []byte, userID string) *http.Request {
req := httptest.NewRequest(http.MethodPut, "/admin/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", userID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
// ---------------------------------------------------------------------------
// Tests: HandleUpdateUser password change notification
// ---------------------------------------------------------------------------
// TestHandleUpdateUser_WithPassword_PublishesEvent asserts that including a
// Password in the update payload publishes one user.password_changed event on
// ChannelSessions targeted at the updated user.
func TestHandleUpdateUser_WithPassword_PublishesEvent(t *testing.T) {
targetUser := &models.User{
ID: 99,
Username: "alice",
Email: "alice@example.com",
Role: "user",
}
repo := &fakeUserRepo{user: targetUser}
h, eventsHub := newAdminHandlerWithHub(repo)
ch, unsub := eventsHub.Subscribe()
defer unsub()
body := []byte(`{"password":"newSecret123"}`)
req := adminUpdateRequest(body, "99")
rec := httptest.NewRecorder()
h.HandleUpdateUser(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
select {
case env := <-ch:
if env.Channel != evt.ChannelSessions {
t.Errorf("channel = %q, want %q", env.Channel, evt.ChannelSessions)
}
if env.Event != notifications.EventUserPasswordChanged {
t.Errorf("event = %q, want %q", env.Event, notifications.EventUserPasswordChanged)
}
if env.UserID != 99 {
t.Errorf("event user_id = %d, want 99", env.UserID)
}
case <-time.After(time.Second):
t.Fatal("expected a user.password_changed event, got none")
}
}
// TestHandleUpdateUser_WithoutPassword_NoEvent asserts that a non-password
// update publishes no user.password_changed event.
func TestHandleUpdateUser_WithoutPassword_NoEvent(t *testing.T) {
targetUser := &models.User{
ID: 99,
Username: "alice",
Email: "alice@example.com",
Role: "user",
}
repo := &fakeUserRepo{user: targetUser}
h, eventsHub := newAdminHandlerWithHub(repo)
ch, unsub := eventsHub.Subscribe()
defer unsub()
body := []byte(`{"username":"alice-renamed"}`)
req := adminUpdateRequest(body, "99")
rec := httptest.NewRecorder()
h.HandleUpdateUser(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
select {
case env := <-ch:
t.Errorf("expected no event, got %q on %q", env.Event, env.Channel)
case <-time.After(100 * time.Millisecond):
// no event, as expected
}
}
@@ -111,7 +111,7 @@ func (h *AdminPlaybackControlHandler) HandleMessageSession(w http.ResponseWriter
return
}
command.Reason = req.Reason
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
result := h.playback.CommandDispatcher.DispatchToSession(command, 0, nil)
if result.DispatchErr != nil {
@@ -173,7 +173,7 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
return
}
command.Reason = req.Reason
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
deadline := boundedPlaybackControlDeadline(req.DeadlineMS)
command.DeadlineMS = int(deadline / time.Millisecond)
@@ -326,47 +326,6 @@ func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) {
}
}
func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
cases := []struct {
key string
value string
restartRequired bool
}{
// Infrastructure settings are captured at startup.
{key: "database.max_connections", value: "40", restartRequired: true},
{key: "s3.public_bucket", value: "assets", restartRequired: true},
// Branding is read live from the settings repo per request.
{key: "branding.server_name", value: "Casa", restartRequired: false},
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
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())
}
var resp adminSettingResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.RestartRequired != tc.restartRequired {
t.Fatalf("restart_required = %v, want %v", resp.RestartRequired, tc.restartRequired)
}
})
}
}
func performSettingsCheckRequest(
t *testing.T,
handler *AdminHandler,
+78 -63
View File
@@ -1,34 +1,82 @@
package handlers
import (
"context"
"net/http"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeAdminUserRepo is a configurable in-memory UserRepository for handler
// tests.
type fakeAdminUserRepo struct {
user *models.User
updateErr error
deleteErr error
}
func (f *fakeAdminUserRepo) List(context.Context) ([]*models.User, error) { return nil, nil }
func (f *fakeAdminUserRepo) Create(context.Context, models.CreateUserInput) (*models.User, error) {
return f.user, nil
}
func (f *fakeAdminUserRepo) Update(context.Context, int, models.UpdateUserInput) error {
return f.updateErr
}
func (f *fakeAdminUserRepo) Delete(context.Context, int) error { return f.deleteErr }
func (f *fakeAdminUserRepo) GetByID(context.Context, int) (*models.User, error) {
if f.user == nil {
return nil, auth.ErrNotFound
}
return f.user, nil
}
// newAdminUsersTestRouter mounts the user-management routes the same way the
// API router does so tests exercise real chi URL params.
func newAdminUsersTestRouter(repo UserRepository) chi.Router {
h := NewAdminHandler(repo, nil, nil)
r := chi.NewRouter()
r.Put("/admin/users/{id}", h.HandleUpdateUser)
r.Delete("/admin/users/{id}", h.HandleDeleteUser)
return r
}
func TestAdminUpdateUserDisableLastAdministrator(t *testing.T) {
repo := &fakeAdminUserRepo{
user: &models.User{ID: 2, Enabled: true},
updateErr: auth.ErrLastAdministrator,
}
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodPut,
"/admin/users/2", map[string]any{"enabled": false})
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestAdminDeleteUserLastAdministrator(t *testing.T) {
repo := &fakeAdminUserRepo{deleteErr: auth.ErrLastAdministrator}
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodDelete,
"/admin/users/2", nil)
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestUpdateRequiresSessionRevocation(t *testing.T) {
role := "admin"
sameRole := "user"
enabled := true
disabled := false
libraryIDs := []int{1, 2}
sameLibraryIDs := []int{1}
emptyLibraryIDs := []int{}
var allLibraryIDs []int
maxPlaybackQuality := "1080p"
sameMaxPlaybackQuality := "original"
password := "new-password"
username := "renamed"
maxStreams := 4
permissions := []string{"metadata_curation"}
samePermissions := []string{"download"}
newGroups := []int{1, 2}
sameGroups := []int{2, 1, 1} // order and duplicates must not matter
emptyGroups := []int{}
current := &models.User{
Role: "user",
Permissions: []string{"download"},
Enabled: false,
LibraryIDs: []int{1},
MaxPlaybackQuality: "original",
Enabled: false,
GroupIDs: []int{1, 2},
}
tests := []struct {
@@ -36,26 +84,6 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
in models.UpdateUserInput
want bool
}{
{
name: "permissions set",
in: models.UpdateUserInput{Permissions: &permissions},
want: true,
},
{
name: "permissions unchanged",
in: models.UpdateUserInput{Permissions: &samePermissions},
want: false,
},
{
name: "role",
in: models.UpdateUserInput{Role: &role},
want: true,
},
{
name: "role unchanged",
in: models.UpdateUserInput{Role: &sameRole},
want: false,
},
{
name: "enabled",
in: models.UpdateUserInput{Enabled: &enabled},
@@ -67,30 +95,15 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
want: false,
},
{
name: "library ids does not revoke session",
in: models.UpdateUserInput{LibraryIDs: &libraryIDs},
name: "groups unchanged",
in: models.UpdateUserInput{GroupIDs: &sameGroups},
want: false,
},
{
name: "library ids unchanged",
in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs},
want: false,
},
{
name: "library ids nil does not revoke session",
in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs},
want: false,
},
{
name: "max playback quality",
in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality},
name: "groups cleared",
in: models.UpdateUserInput{GroupIDs: &emptyGroups},
want: true,
},
{
name: "max playback quality unchanged",
in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality},
want: false,
},
{
name: "password",
in: models.UpdateUserInput{Password: &password},
@@ -98,7 +111,7 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
},
{
name: "non access fields",
in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams},
in: models.UpdateUserInput{Username: &username},
want: false,
},
{
@@ -116,16 +129,18 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
})
}
unrestrictedCurrent := *current
unrestrictedCurrent.LibraryIDs = nil
t.Run("library ids empty does not revoke session", func(t *testing.T) {
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
memberless := &models.User{Enabled: true, GroupIDs: nil}
t.Run("groups assigned to memberless user", func(t *testing.T) {
if got := updateRequiresSessionRevocation(memberless, models.UpdateUserInput{GroupIDs: &newGroups}); !got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
}
})
t.Run("library ids nil unchanged", func(t *testing.T) {
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got {
t.Run("unknown current user falls back to may-require", func(t *testing.T) {
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{GroupIDs: &sameGroups}); !got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
}
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{Username: &username}); got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
}
})
+2 -2
View File
@@ -344,7 +344,7 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
}
const ttl = 5 * time.Minute
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.SessionID, ttl)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
return
@@ -527,7 +527,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Role: auth.RoleForUser(user),
Permissions: auth.EffectivePermissions(user),
DownloadAllowed: user.DownloadAllowed,
}
@@ -24,7 +24,6 @@ func newEbookReaderAuthRequest(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
@@ -421,7 +420,6 @@ func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
@@ -524,7 +522,6 @@ func TestEbookReaderSavesConfigForAccessibleEbook(t *testing.T) {
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
@@ -608,7 +605,6 @@ func TestEbookReaderCreatesAnnotationForAccessibleEbook(t *testing.T) {
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
+91 -72
View File
@@ -12,8 +12,7 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/gorilla/websocket"
@@ -33,10 +32,9 @@ type activeScanLister interface {
ListActive(ctx context.Context) ([]evt.ScanRun, error)
}
// notificationUnreadCounter is the subset of notifications.Service used by EventsHandler.
type notificationUnreadCounter interface {
UnreadCount(ctx context.Context, userID int, profileID string, childSafe bool) (int, error)
}
// viewerRevalidateInterval is how often an open events WebSocket re-loads its
// user to confirm the account is still enabled and admin status is unchanged.
const viewerRevalidateInterval = 60 * time.Second
type EventsHandler struct {
hub *evt.Hub
@@ -46,8 +44,11 @@ type EventsHandler struct {
scans *evt.ScanRegistry
persistedScans activeScanLister
historyImports historyImportActiveLister
notifications notificationUnreadCounter
presence presence.Registry
users auth.UserLoader
// revalidateInterval overrides viewerRevalidateInterval in tests; zero
// means the default.
revalidateInterval time.Duration
}
func NewEventsHandler(
@@ -58,8 +59,7 @@ func NewEventsHandler(
scans *evt.ScanRegistry,
persistedScans *scanqueue.Service,
historyImports historyImportActiveLister,
notificationsSvc *notifications.Service,
presenceReg presence.Registry,
users auth.UserLoader,
) *EventsHandler {
return &EventsHandler{
hub: hub,
@@ -69,8 +69,7 @@ func NewEventsHandler(
scans: scans,
persistedScans: persistedScans,
historyImports: historyImports,
notifications: notificationsSvc,
presence: presenceReg,
users: users,
}
}
@@ -86,6 +85,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
}
// Admin status is checked server-side once per connection; it never comes
// from token contents.
viewerIsAdmin := isAdminRequest(r, h.users)
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
@@ -98,20 +101,11 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
eventsCh, unsubscribe := h.hub.Subscribe()
defer unsubscribe()
if h.presence != nil {
release := h.presence.Add(ctx, claims.UserID)
defer release()
}
startWebSocketPingLoop(ctx, func() error {
if h.presence != nil {
h.presence.Refresh(ctx, claims.UserID)
}
return writeWebSocketControl(conn, websocket.PingMessage, nil)
})
allowedChannels := allowedChannelsForRole(claims.Role)
allowedChannels := allowedChannelsForViewer(viewerIsAdmin)
connectionID := ulid.Make().String()
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
Type: "hello",
@@ -143,6 +137,17 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
// Channel authorization is decided once at upgrade, so a long-lived
// connection must periodically re-load its user: a disabled account or a
// changed admin role would otherwise keep its channel set until the
// client disconnects on its own.
revalidateEvery := h.revalidateInterval
if revalidateEvery <= 0 {
revalidateEvery = viewerRevalidateInterval
}
revalidate := time.NewTicker(revalidateEvery)
defer revalidate.Stop()
subscriptions := make(map[evt.EventChannel]struct{})
subscribedOnce := false
@@ -152,6 +157,33 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
case <-readDone:
return
case <-revalidate.C:
if h.users == nil {
// No loader wired: the viewer was never granted admin
// channels (isAdminRequest fails closed), so there is no
// stale privilege to revoke.
continue
}
user, err := h.users.GetByID(ctx, claims.UserID)
if err != nil && !auth.IsNotFound(err) {
// Transient lookup failure: keep the connection and retry on
// the next tick rather than dropping every viewer on a DB
// blip. A definitive miss (ErrNotFound) leaves user nil and
// closes below.
slog.Warn("events: viewer revalidation failed; retrying next tick",
"user_id", claims.UserID,
"error", err,
)
continue
}
if closeConn, reason := revalidateViewer(user, viewerIsAdmin); closeConn {
_ = writeWebSocketControl(
conn,
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason),
)
return
}
case <-deadline.C:
if subscribedOnce {
continue
@@ -164,7 +196,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
)
return
case data := <-readMessages:
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels)
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, viewerIsAdmin, data, allowedChannels)
if !ok {
return
}
@@ -186,10 +218,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
if _, subscribed := subscriptions[env.Channel]; !subscribed {
continue
}
if !allowsEventForClaims(claims, env) {
if !allowsEventForViewer(claims.UserID, viewerIsAdmin, env) {
continue
}
if err := h.writeEventFrame(conn, r, claims, env); err != nil {
if err := h.writeEventFrame(conn, r, claims, viewerIsAdmin, env); err != nil {
return
}
}
@@ -200,6 +232,7 @@ func (h *EventsHandler) handleEventsClientMessage(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
data []byte,
allowed []evt.EventChannel,
) (map[evt.EventChannel]struct{}, bool, bool) {
@@ -284,7 +317,7 @@ func (h *EventsHandler) handleEventsClientMessage(
}
for _, channel := range accepted {
if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil {
if err := h.writeSnapshotFrame(conn, r, claims, viewerIsAdmin, channel); err != nil {
return nil, false, false
}
}
@@ -292,15 +325,32 @@ func (h *EventsHandler) handleEventsClientMessage(
return nextSubs, true, true
}
func allowedChannelsForRole(role string) []evt.EventChannel {
// revalidateViewer decides whether an open events WebSocket must be closed
// after its user is re-loaded. The connection closes when the account is gone
// or disabled, and when admin status changed in either direction — the
// allowed channel set is fixed at upgrade, so the client must reconnect to
// receive the correct set; closing on change is simpler and safer than
// re-assigning channels mid-stream. Other policy changes (AccessPolicyRevision
// bumps from group/library edits) deliberately keep the connection: event
// authorization (allowedChannelsForViewer, allowsEventForViewer) depends only
// on admin status and user ID, never on per-user library or rating policy.
func revalidateViewer(user *models.User, wasAdmin bool) (closeConn bool, reason string) {
if user == nil || !user.Enabled {
return true, "account disabled"
}
if user.IsAdmin != wasAdmin {
return true, "permissions changed"
}
return false, ""
}
func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
channels := []evt.EventChannel{
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelNotifications,
evt.ChannelRequests,
evt.ChannelUserState,
}
if role == "admin" {
if viewerIsAdmin {
channels = append(channels,
evt.ChannelJobs,
evt.ChannelSessions,
@@ -311,31 +361,19 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
return channels
}
func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
if claims == nil {
func allowsEventForViewer(viewerUserID int, viewerIsAdmin bool, env evt.Envelope) bool {
if viewerUserID == 0 {
return false
}
if env.AdminOnly && claims.Role != "admin" {
if env.AdminOnly && !viewerIsAdmin {
return false
}
if env.UserID > 0 && env.UserID != claims.UserID {
// The notifications and requests channels carry per-user private content
// (inbox titles/bodies, requested media names). Nobody — including
// admins — may receive another user's events on them. Other channels
// keep the admin cross-user monitoring bypass.
if claims.Role != "admin" || isPersonalChannel(env.Channel) {
return false
}
if env.UserID > 0 && !viewerIsAdmin && env.UserID != viewerUserID {
return false
}
return true
}
// isPersonalChannel reports whether a channel carries per-user private content
// that must never be delivered across user boundaries, even to admins.
func isPersonalChannel(c evt.EventChannel) bool {
return c == evt.ChannelNotifications || c == evt.ChannelRequests
}
func marshalJSON(value any) json.RawMessage {
if value == nil {
return json.RawMessage("null")
@@ -350,33 +388,12 @@ func marshalJSON(value any) json.RawMessage {
func (h *EventsHandler) snapshotForChannel(
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
channel evt.EventChannel,
) (json.RawMessage, error) {
switch channel {
case evt.ChannelCatalog, evt.ChannelUserState:
return json.RawMessage("null"), nil
case evt.ChannelNotifications:
if h == nil || h.notifications == nil {
return json.RawMessage(`{"unread_count":0}`), nil
}
// Profile ID is read directly from the WS upgrade request header because
// the events/ws route does not go through the RequireProfile middleware.
// childSafe is intentionally false here: the EventsHandler has no access
// to a profile store, so the WS snapshot is a conservative approximation.
// The REST GET /notifications/unread-count endpoint is authoritative for
// child-safe profile filtering.
profileID := r.Header.Get("X-Profile-Id")
count, err := h.notifications.UnreadCount(r.Context(), claims.UserID, profileID, false)
if err != nil {
return nil, err
}
return marshalJSON(struct {
UnreadCount int `json:"unread_count"`
}{UnreadCount: count}), nil
case evt.ChannelRequests:
// Request state is fetched via REST; the WS channel only carries
// lifecycle events. Return an empty object as the initial snapshot.
return json.RawMessage(`{}`), nil
case evt.ChannelJobs:
if h == nil || h.jobs == nil || h.jobs.repo == nil {
return json.RawMessage("[]"), nil
@@ -424,7 +441,7 @@ func (h *EventsHandler) snapshotForChannel(
if h == nil || h.historyImports == nil {
return json.RawMessage("[]"), nil
}
if claims != nil && claims.Role == "admin" {
if viewerIsAdmin {
runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil)
if err != nil {
return nil, err
@@ -445,9 +462,10 @@ func (h *EventsHandler) writeSnapshotFrame(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
channel evt.EventChannel,
) error {
data, err := h.snapshotForChannel(r, claims, channel)
data, err := h.snapshotForChannel(r, claims, viewerIsAdmin, channel)
if err != nil {
slog.Error(
"events: failed to build initial snapshot",
@@ -473,11 +491,12 @@ func (h *EventsHandler) writeEventFrame(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
env evt.Envelope,
) error {
data := env.Data
if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") {
snapshot, err := h.snapshotForChannel(r, claims, env.Channel)
snapshot, err := h.snapshotForChannel(r, claims, viewerIsAdmin, env.Channel)
if err != nil {
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
return err
+150 -105
View File
@@ -3,123 +3,168 @@ package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
)
// ---------------------------------------------------------------------------
// Fake notificationUnreadCounter for snapshot tests.
// ---------------------------------------------------------------------------
type fakeUnreadCounter struct {
count int
err error
}
func (f *fakeUnreadCounter) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
return f.count, f.err
}
// ---------------------------------------------------------------------------
// allowedChannelsForRole tests
// ---------------------------------------------------------------------------
func TestAllowedChannelsForRole_UserContainsNotificationsAndRequests(t *testing.T) {
channels := allowedChannelsForRole("user")
if !slices.Contains(channels, evt.ChannelNotifications) {
t.Errorf("allowedChannelsForRole(user) missing ChannelNotifications; got %v", channels)
func TestRevalidateViewer(t *testing.T) {
cases := []struct {
name string
user *models.User
wasAdmin bool
wantClose bool
}{
{
name: "user deleted",
user: nil,
wasAdmin: false,
wantClose: true,
},
{
name: "user disabled",
user: &models.User{ID: 1, Enabled: false},
wasAdmin: false,
wantClose: true,
},
{
name: "admin disabled keeps no access even with admin flag",
user: &models.User{ID: 1, Enabled: false, IsAdmin: true},
wasAdmin: true,
wantClose: true,
},
{
name: "admin demoted",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
wasAdmin: true,
wantClose: true,
},
{
name: "viewer promoted to admin",
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
wasAdmin: false,
wantClose: true,
},
{
name: "enabled non-admin unchanged",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
wasAdmin: false,
wantClose: false,
},
{
name: "enabled admin unchanged",
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
wasAdmin: true,
wantClose: false,
},
{
// Policy revision bumps (group/library edits) do not affect event
// authorization, which depends only on admin status and user ID.
name: "policy revision change without admin change",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false, AccessPolicyRevision: 42},
wasAdmin: false,
wantClose: false,
},
}
if !slices.Contains(channels, evt.ChannelRequests) {
t.Errorf("allowedChannelsForRole(user) missing ChannelRequests; got %v", channels)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
closeConn, reason := revalidateViewer(tc.user, tc.wasAdmin)
if closeConn != tc.wantClose {
t.Fatalf("revalidateViewer() close = %v, want %v", closeConn, tc.wantClose)
}
if closeConn && reason == "" {
t.Fatal("expected a close reason")
}
if !closeConn && reason != "" {
t.Fatalf("expected empty reason for kept connection, got %q", reason)
}
})
}
}
func TestAllowedChannelsForRole_AdminContainsAllUserChannelsPlusExtras(t *testing.T) {
userChannels := allowedChannelsForRole("user")
adminChannels := allowedChannelsForRole("admin")
// mutableUserLoader is a swappable auth.UserLoader for revalidation tests.
type mutableUserLoader struct {
mu sync.Mutex
user *models.User
err error
}
for _, ch := range userChannels {
if !slices.Contains(adminChannels, ch) {
t.Errorf("allowedChannelsForRole(admin) missing user channel %q", ch)
}
}
adminExtras := []evt.EventChannel{
evt.ChannelJobs,
evt.ChannelSessions,
evt.ChannelTasks,
evt.ChannelScans,
}
for _, ch := range adminExtras {
if !slices.Contains(adminChannels, ch) {
t.Errorf("allowedChannelsForRole(admin) missing admin-only channel %q", ch)
func (l *mutableUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.err != nil {
return nil, l.err
}
if l.user == nil || l.user.ID != id {
return nil, auth.ErrNotFound
}
userCopy := *l.user
return &userCopy, nil
}
func (l *mutableUserLoader) set(user *models.User, err error) {
l.mu.Lock()
l.user = user
l.err = err
l.mu.Unlock()
}
// TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection covers the
// revalidation ticker end to end: an open connection is closed by the server
// once the periodic user re-load reports the account disabled.
func TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection(t *testing.T) {
loader := &mutableUserLoader{user: &models.User{ID: 1, Enabled: true}}
handler := NewEventsHandler(evt.NewHub("test", nil), nil, nil, nil, nil, nil, nil, loader)
handler.revalidateInterval = 20 * time.Millisecond
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
handler.HandleWebSocket(w, r.WithContext(ctx))
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatalf("Dial websocket: %v", err)
}
defer conn.Close()
// Complete the handshake: hello, subscribe, subscribed + snapshot.
var hello evt.EventsHelloMessage
if err := conn.ReadJSON(&hello); err != nil {
t.Fatalf("read hello: %v", err)
}
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
Type: "subscribe",
Channels: []evt.EventChannel{evt.ChannelCatalog},
}); err != nil {
t.Fatalf("write subscribe: %v", err)
}
// The account is disabled while the connection is open; the next
// revalidation tick must close it with a normal close frame.
loader.set(&models.User{ID: 1, Enabled: false}, nil)
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
for {
var frame json.RawMessage
if err := conn.ReadJSON(&frame); err != nil {
var closeErr *websocket.CloseError
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return // server closed the connection as required
}
t.Fatalf("expected normal close, got %v (%T %v)", err, err, closeErr)
}
}
}
// ---------------------------------------------------------------------------
// snapshotForChannel tests
// ---------------------------------------------------------------------------
func TestSnapshotForChannel_NotificationsNilService(t *testing.T) {
h := &EventsHandler{notifications: nil}
r := httptest.NewRequest("GET", "/events/ws", nil)
claims := &auth.Claims{UserID: 42, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
UnreadCount int `json:"unread_count"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if got.UnreadCount != 0 {
t.Errorf("nil service: want unread_count=0, got %d", got.UnreadCount)
}
}
func TestSnapshotForChannel_NotificationsWithService(t *testing.T) {
h := &EventsHandler{notifications: &fakeUnreadCounter{count: 7}}
r := httptest.NewRequest("GET", "/events/ws", nil)
r.Header.Set("X-Profile-Id", "profile-abc")
claims := &auth.Claims{UserID: 5, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
UnreadCount int `json:"unread_count"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if got.UnreadCount != 7 {
t.Errorf("want unread_count=7, got %d", got.UnreadCount)
}
}
func TestSnapshotForChannel_RequestsReturnsEmptyObject(t *testing.T) {
h := &EventsHandler{}
r := httptest.NewRequest("GET", "/events/ws", nil)
claims := &auth.Claims{UserID: 1, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelRequests)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if len(got) != 0 {
t.Errorf("ChannelRequests snapshot should be empty object, got %v", got)
// Drain subscribed/snapshot frames until the close arrives.
}
}
-3
View File
@@ -1800,9 +1800,6 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
if claims == nil {
return false
}
if claims.Role == "admin" {
return true
}
if h == nil || h.UserRepo == nil {
return false
}
+62
View File
@@ -0,0 +1,62 @@
package handlers
// This file holds tri-state JSON field wrappers for PATCH-style request
// bodies. Plain Go pointers can only distinguish two states (absent/nil vs.
// value); these wrappers track three:
//
// - absent: the key was not present in the JSON body (Set = false) —
// the caller should leave the current value unchanged
// - null: the key was present with an explicit JSON null (Set = true)
// - value: the key was present with a concrete value (Set = true)
//
// How null is decoded depends on the wrapper: optionalIntSliceField keeps a
// nil slice (e.g. "all libraries"), optionalStringSliceField normalizes null
// to an empty slice.
import (
"bytes"
"encoding/json"
)
// optionalIntSliceField distinguishes an absent JSON field from an explicit
// null or array value. Absent = don't update; null = nil slice ("all
// libraries"); [] = empty slice ("none").
type optionalIntSliceField struct {
Set bool
Value []int
}
func (f *optionalIntSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = nil
return nil
}
return json.Unmarshal(data, &f.Value)
}
// Ptr returns nil when the field was absent, otherwise a pointer to the
// decoded slice (which may itself be nil for JSON null).
func (f optionalIntSliceField) Ptr() *[]int {
if !f.Set {
return nil
}
value := f.Value
return &value
}
// optionalStringSliceField distinguishes an absent JSON field from an
// explicit null or array value. JSON null decodes to an empty slice.
type optionalStringSliceField struct {
Set bool
Value []string
}
func (f *optionalStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
-3
View File
@@ -288,9 +288,6 @@ func (h *MarkersHandler) requireMarkerEdit(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return false
}
if claims.Role == "admin" {
return true
}
if h == nil || h.Users == nil {
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
return false
+15 -9
View File
@@ -90,7 +90,6 @@ func markerItemPutRequest(body string) *http.Request {
func withMarkerAdminClaims(req *http.Request) *http.Request {
return req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
Role: "admin",
TokenType: auth.TokenTypeAccess,
SessionID: "session-1",
}))
@@ -98,7 +97,12 @@ func withMarkerAdminClaims(req *http.Request) *http.Request {
func newMarkersHandler(writer ManualMarkerWriter) *MarkersHandler {
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
return NewMarkersHandler(files, writer, nil, nil, nil, nil)
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
h.Users = fakeMarkerUsers{
1: {ID: 1, Enabled: true, IsAdmin: true},
7: {ID: 7, Enabled: true, IsAdmin: true},
}
return h
}
func TestSetFileMarkersWritesManual(t *testing.T) {
@@ -131,6 +135,7 @@ func TestSetItemMarkersWritesPrimaryEpisodeFile(t *testing.T) {
episodeFiles: []*models.MediaFile{file},
}
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
rec := httptest.NewRecorder()
h.HandleSetItemMarkers(rec, markerItemPutRequest(`{"recap":{"start":0,"end":45}}`))
@@ -209,13 +214,13 @@ func TestGetItemMarkersUsesFirstAuthorizedFile(t *testing.T) {
func TestGetFileMarkersDoesNotRequireMarkerEditPermission(t *testing.T) {
h := newMarkersHandler(nil)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
}
req := httptest.NewRequest(http.MethodGet, "/markers/files/5", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("fileId", "5")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleGetFileMarkers(rec, req)
@@ -229,10 +234,10 @@ func TestSetFileMarkersRejectsUserWithoutMarkerEditPermission(t *testing.T) {
writer := &fakeMarkerWriter{}
h := newMarkersHandler(writer)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
}
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, req)
@@ -249,10 +254,10 @@ func TestSetFileMarkersAllowsUserWithMarkerEditPermission(t *testing.T) {
writer := &fakeMarkerWriter{}
h := newMarkersHandler(writer)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{"marker_edit"}},
7: &models.User{ID: 7, Enabled: true, Permissions: []string{"marker_edit"}},
}
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, req)
@@ -274,7 +279,6 @@ func TestSetFileMarkersPassesAuditContextToWriter(t *testing.T) {
ctx := clientip.SetContext(req.Context(), "203.0.113.10")
ctx = apimw.SetClaims(ctx, &auth.Claims{
UserID: 7,
Role: "admin",
TokenType: auth.TokenTypeAPIKey,
APIKeyID: apiKeyID,
})
@@ -382,6 +386,7 @@ func TestSetFileMarkersTriggersBackgroundContribution(t *testing.T) {
called := make(chan markers.ContributeOptions, 1)
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, markerPutRequest(`{"intro":{"start":0,"end":60}}`))
@@ -403,6 +408,7 @@ func TestSetFileMarkersClearOnlyDoesNotContribute(t *testing.T) {
called := make(chan markers.ContributeOptions, 1)
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, markerPutRequest(`{"credits":null}`))
-316
View File
@@ -1,316 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// profileResolver is the minimal interface NotificationsHandler needs from the
// userstore provider. Defined on the handler side so tests can stub it without
// importing the full provider.
type profileResolver interface {
ForUser(ctx context.Context, userID int) (userstore.UserStore, error)
}
// NotificationsHandler handles inbox, preferences, and announcement endpoints.
type NotificationsHandler struct {
svc *notifications.Service
profiles profileResolver // nil-tolerant: ChildSafe=false when absent
}
// NewNotificationsHandler creates a new NotificationsHandler.
// provider may be nil; when nil, ChildSafe defaults to false on all requests.
func NewNotificationsHandler(svc *notifications.Service, provider ...profileResolver) *NotificationsHandler {
h := &NotificationsHandler{svc: svc}
if len(provider) > 0 {
h.profiles = provider[0]
}
return h
}
// childSafe resolves the active profile and returns Profile.IsChild.
// Fails closed: an unresolvable profile is treated as a child so restricted
// categories are never leaked on errors. Logs at debug level only — never fails
// the request. Nil resolver / missing ids keep returning false (unauthenticated
// paths never reach here; that's the wiring-absent case).
func (h *NotificationsHandler) childSafe(r *http.Request, userID int, profileID string) bool {
if h.profiles == nil || userID == 0 || profileID == "" {
return false
}
store, err := h.profiles.ForUser(r.Context(), userID)
if err != nil {
slog.Debug("notifications: childSafe: ForUser failed", "user_id", userID, "error", err)
return true // fail closed: treat unknown as child
}
p, err := store.GetProfile(r.Context(), profileID)
if err != nil || p == nil {
slog.Debug("notifications: childSafe: GetProfile failed", "profile_id", profileID, "error", err)
return true // fail closed: treat unknown as child
}
return p.IsChild
}
// ---------------------------------------------------------------------------
// User-scoped inbox endpoints
// ---------------------------------------------------------------------------
// HandleList handles GET /notifications.
// Query params: unread=1, category, cursor (int64), limit (int, default 50, max 100).
// Response: {"items":[...],"next_cursor":<int64|null>}
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
q := r.URL.Query()
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
// keep in sync with the clamp in internal/notifications/store.go List
if limit > 100 {
limit = 100
}
f := notifications.ListFilter{
UserID: userID,
ProfileID: profileID,
Limit: limit,
ChildSafe: h.childSafe(r, userID, profileID),
}
if q.Get("unread") == "1" {
f.UnreadOnly = true
}
if cat := q.Get("category"); cat != "" {
f.Category = notifications.Category(cat)
}
if v := q.Get("cursor"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
f.Cursor = n
}
}
items, err := h.svc.List(r.Context(), f)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
return
}
var nextCursor *int64
if len(items) == limit && limit > 0 {
last := items[len(items)-1].ID
nextCursor = &last
}
writeJSON(w, http.StatusOK, struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}{
Items: items,
NextCursor: nextCursor,
})
}
// HandleUnreadCount handles GET /notifications/unread-count.
// Response: {"count": n}
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
count, err := h.svc.UnreadCount(r.Context(), userID, profileID, h.childSafe(r, userID, profileID))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get unread count")
return
}
writeJSON(w, http.StatusOK, struct {
Count int `json:"count"`
}{Count: count})
}
// markReadRequest is the request body for POST /notifications/read.
type markReadRequest struct {
IDs []int64 `json:"ids"`
All bool `json:"all"`
}
// HandleMarkRead handles POST /notifications/read.
// Body: {"ids":[...]} OR {"all":true}. 400 when neither present.
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req markReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if !req.All && len(req.IDs) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Provide ids or all:true")
return
}
if req.All {
if err := h.svc.MarkAllRead(r.Context(), userID, profileID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark all read")
return
}
} else {
if err := h.svc.MarkRead(r.Context(), userID, profileID, req.IDs); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDismiss handles POST /notifications/{id}/dismiss.
// 400 for invalid id; 404 on ErrNotFound; 204 on success.
func (h *NotificationsHandler) HandleDismiss(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
rawID := chi.URLParam(r, "id")
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid notification id")
return
}
if err := h.svc.Dismiss(r.Context(), userID, profileID, id); err != nil {
if errors.Is(err, notifications.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to dismiss notification")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleGetPreferences handles GET /notifications/preferences.
// Response: {"preferences":[{category,enabled},...]}
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
prefs, err := h.svc.GetPreferences(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get preferences")
return
}
writeJSON(w, http.StatusOK, struct {
Preferences []notifications.Preference `json:"preferences"`
}{Preferences: prefs})
}
// putPreferencesRequest is the request body for PUT /notifications/preferences.
type putPreferencesRequest struct {
Preferences []notifications.Preference `json:"preferences"`
}
// HandlePutPreferences handles PUT /notifications/preferences.
// 400 on empty or invalid category; 204 on success.
func (h *NotificationsHandler) HandlePutPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
var req putPreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if len(req.Preferences) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "preferences must not be empty")
return
}
if err := h.svc.SetPreferences(r.Context(), userID, req.Preferences); err != nil {
if errors.Is(err, notifications.ErrInvalidCategory) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save preferences")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Admin announcement endpoints
// ---------------------------------------------------------------------------
// HandleListAnnouncements handles GET /admin/announcements.
// Response: {"items":[...]}
func (h *NotificationsHandler) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.ListAnnouncements(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list announcements")
return
}
if items == nil {
items = []*notifications.Announcement{}
}
writeJSON(w, http.StatusOK, struct {
Items []*notifications.Announcement `json:"items"`
}{Items: items})
}
// HandleCreateAnnouncement handles POST /admin/announcements.
// Decodes an Announcement, sets CreatedBy from claims, publishes; 400 on
// validation error; 201 with the announcement on success.
func (h *NotificationsHandler) HandleCreateAnnouncement(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
// Announcements fan out one row per (user, profile); cap the request body so
// a large title/body cannot be amplified into the whole server's inboxes.
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
var a notifications.Announcement
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if claims != nil {
userID := claims.UserID
a.CreatedBy = &userID
}
if err := h.svc.PublishAnnouncement(r.Context(), &a); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusCreated, &a)
}
// HandleDeleteAnnouncement handles DELETE /admin/announcements/{id}.
// 404 on ErrNotFound; 204 on success.
func (h *NotificationsHandler) HandleDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
rawID := chi.URLParam(r, "id")
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid announcement id")
return
}
if err := h.svc.DeleteAnnouncement(r.Context(), id); err != nil {
if errors.Is(err, notifications.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Announcement not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete announcement")
return
}
w.WriteHeader(http.StatusNoContent)
}
-673
View File
@@ -1,673 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ---------------------------------------------------------------------------
// Fake profile resolver for child-safe tests.
// ---------------------------------------------------------------------------
// fakeProfileStore satisfies userstore.UserStore minimally.
// Only GetProfile is exercised by childSafe.
type fakeProfileStore struct {
userstore.UserStore // embed for unimplemented methods
isChild bool
}
func (s *fakeProfileStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
return &userstore.Profile{ID: id, IsChild: s.isChild}, nil
}
// fakeResolverAsProfileResolver implements the profileResolver interface used
// by NotificationsHandler.
type fakeResolverAsProfileResolver struct {
isChild bool
}
func (f *fakeResolverAsProfileResolver) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
return &fakeProfileStore{isChild: f.isChild}, nil
}
// fakeResolverWithError returns an error from ForUser.
type fakeResolverWithError struct{}
func (f *fakeResolverWithError) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
return nil, errors.New("resolver failed")
}
// ---------------------------------------------------------------------------
// Fake store implementing notifications.Store for handler tests.
// ---------------------------------------------------------------------------
type fakeNotificationsStore struct {
notifications.Store // embed for unimplemented panics
list []*notifications.Notification
announcements []*notifications.Announcement
unreadCount int
prefs map[notifications.Category]bool
markReadCalled bool
markAllReadCalled bool
dismissErr error
setPrefErr error
deleteAnnouncementErr error
insertCalled bool
// capturedFilter records the most recent ListFilter passed to List.
capturedFilter *notifications.ListFilter
}
func (f *fakeNotificationsStore) Insert(_ context.Context, n *notifications.Notification) (bool, error) {
f.insertCalled = true
f.list = append(f.list, n)
return true, nil
}
func (f *fakeNotificationsStore) List(_ context.Context, filter notifications.ListFilter) ([]*notifications.Notification, error) {
f.capturedFilter = &filter
return f.list, nil
}
func (f *fakeNotificationsStore) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
return f.unreadCount, nil
}
func (f *fakeNotificationsStore) MarkRead(_ context.Context, _ int, _ string, _ []int64) error {
f.markReadCalled = true
return nil
}
func (f *fakeNotificationsStore) MarkAllRead(_ context.Context, _ int, _ string) error {
f.markAllReadCalled = true
return nil
}
func (f *fakeNotificationsStore) Dismiss(_ context.Context, _ int, _ string, _ int64) error {
return f.dismissErr
}
func (f *fakeNotificationsStore) Preferences(_ context.Context, _ int) (map[notifications.Category]bool, error) {
if f.prefs != nil {
return f.prefs, nil
}
return map[notifications.Category]bool{}, nil
}
func (f *fakeNotificationsStore) SetPreferences(_ context.Context, _ int, _ []notifications.Preference) error {
return f.setPrefErr
}
func (f *fakeNotificationsStore) InsertAnnouncement(_ context.Context, a *notifications.Announcement) error {
a.ID = int64(len(f.announcements) + 1)
a.CreatedAt = time.Now().UTC()
f.announcements = append(f.announcements, a)
return nil
}
func (f *fakeNotificationsStore) ListAnnouncements(_ context.Context) ([]*notifications.Announcement, error) {
return f.announcements, nil
}
func (f *fakeNotificationsStore) DeleteAnnouncement(_ context.Context, id int64) error {
if f.deleteAnnouncementErr != nil {
return f.deleteAnnouncementErr
}
for i, a := range f.announcements {
if a.ID == id {
f.announcements = append(f.announcements[:i], f.announcements[i+1:]...)
return nil
}
}
return notifications.ErrNotFound
}
func (f *fakeNotificationsStore) DismissAnnouncementNotifications(_ context.Context, _ int64) error {
return nil
}
func (f *fakeNotificationsStore) ProfileIDsForUsers(_ context.Context, _ []int) (map[int][]string, error) {
return nil, nil
}
func (f *fakeNotificationsStore) PurgeOld(_ context.Context, _, _ time.Time) (int64, error) {
return 0, nil
}
func (f *fakeNotificationsStore) AdminUserIDs(_ context.Context) ([]int, error) { return nil, nil }
func (f *fakeNotificationsStore) UserIDsWithLibraryAccess(_ context.Context, _ int) ([]int, error) {
return nil, nil
}
func (f *fakeNotificationsStore) AllEnabledUserIDs(_ context.Context) ([]int, error) { return nil, nil }
// Compile-time check.
var _ notifications.Store = (*fakeNotificationsStore)(nil)
// ---------------------------------------------------------------------------
// Helper: build a handler over the fake store.
// ---------------------------------------------------------------------------
func newTestNotificationsHandler(store *fakeNotificationsStore) *NotificationsHandler {
hub := evt.NewHub("test", nil)
svc := notifications.NewService(store, hub)
return NewNotificationsHandler(svc)
}
func newTestNotificationsHandlerWithResolver(store *fakeNotificationsStore, resolver profileResolver) *NotificationsHandler {
hub := evt.NewHub("test", nil)
svc := notifications.NewService(store, hub)
return NewNotificationsHandler(svc, resolver)
}
// notifUserRequest creates an authenticated request with a profile ID.
func notifUserRequest(method, target string, body []byte, userID int, profileID string) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
ctx = apimw.SetProfileID(ctx, profileID)
return req.WithContext(ctx)
}
// notifAdminRequest creates an admin-authenticated request.
func notifAdminRequest(method, target string, body []byte, userID int) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "admin"})
return req.WithContext(ctx)
}
// notifChiRequest adds a chi URL param to a request context.
func notifChiRequest(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// ---------------------------------------------------------------------------
// Tests: HandleList
// ---------------------------------------------------------------------------
func TestNotificationsHandleList_HappyPath_TwoRowsNoCursor(t *testing.T) {
now := time.Now().UTC()
store := &fakeNotificationsStore{
list: []*notifications.Notification{
{ID: 2, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
{ID: 1, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Items) != 2 {
t.Fatalf("items count = %d, want 2", len(resp.Items))
}
// len < limit (default 50), so next_cursor must be null.
if resp.NextCursor != nil {
t.Fatalf("next_cursor = %v, want nil (items < limit)", resp.NextCursor)
}
}
// ---------------------------------------------------------------------------
// Tests: HandleUnreadCount
// ---------------------------------------------------------------------------
func TestNotificationsHandleUnreadCount_ReturnsCount(t *testing.T) {
store := &fakeNotificationsStore{unreadCount: 7}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications/unread-count", nil, 1, "prof-1")
h.HandleUnreadCount(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
Count int `json:"count"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Count != 7 {
t.Fatalf("count = %d, want 7", resp.Count)
}
}
// ---------------------------------------------------------------------------
// Tests: HandleMarkRead
// ---------------------------------------------------------------------------
func TestNotificationsHandleMarkRead_EmptyBody_Returns400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleMarkRead_AllTrue_Returns204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"all":true}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if !store.markAllReadCalled {
t.Fatal("MarkAllRead was not called")
}
}
func TestNotificationsHandleMarkRead_IDs_Returns204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"ids":[1,2,3]}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if !store.markReadCalled {
t.Fatal("MarkRead was not called")
}
}
// ---------------------------------------------------------------------------
// Tests: HandleDismiss
// ---------------------------------------------------------------------------
func TestNotificationsHandleDismiss_NotFound_Returns404(t *testing.T) {
store := &fakeNotificationsStore{dismissErr: notifications.ErrNotFound}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/999/dismiss", nil, 1, "prof-1")
req = notifChiRequest(req, "id", "999")
h.HandleDismiss(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDismiss_BadID_Returns400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/abc/dismiss", nil, 1, "prof-1")
req = notifChiRequest(req, "id", "abc")
h.HandleDismiss(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Tests: HandleGetPreferences
// ---------------------------------------------------------------------------
func TestNotificationsHandleGetPreferences_DefaultsReturned(t *testing.T) {
// No stored prefs → service returns all MutableCategories with defaults.
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications/preferences", nil, 1, "prof-1")
h.HandleGetPreferences(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Preferences []notifications.Preference `json:"preferences"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Preferences) != len(notifications.MutableCategories) {
t.Fatalf("prefs count = %d, want %d", len(resp.Preferences), len(notifications.MutableCategories))
}
prefMap := make(map[notifications.Category]bool)
for _, p := range resp.Preferences {
prefMap[p.Category] = p.Enabled
}
// content_digest defaults to false (opt-in); all others default to true.
for _, cat := range notifications.MutableCategories {
enabled, ok := prefMap[cat]
if !ok {
t.Errorf("category %q missing from preferences", cat)
continue
}
if cat == notifications.CategoryContentDigest {
if enabled {
t.Errorf("content_digest default should be false (opt-in), got true")
}
} else {
if !enabled {
t.Errorf("category %q default should be true, got false", cat)
}
}
}
}
// ---------------------------------------------------------------------------
// Tests: HandlePutPreferences
// ---------------------------------------------------------------------------
func TestNotificationsHandlePutPreferences_RejectsAnnouncementCategory_400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
// "announcement" is not in MutableCategories.
body := `{"preferences":[{"category":"announcement","enabled":false}]}`
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
h.HandlePutPreferences(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandlePutPreferences_ValidPrefs_204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
body := `{"preferences":[{"category":"request","enabled":false},{"category":"content","enabled":true}]}`
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
h.HandlePutPreferences(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Tests: Admin announcements
// ---------------------------------------------------------------------------
func TestNotificationsHandleCreateAnnouncement_201(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
body := `{"title":"Test Announcement","body":"Hello","audience":{"all":true}}`
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
h.HandleCreateAnnouncement(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String())
}
var resp notifications.Announcement
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Title != "Test Announcement" {
t.Fatalf("title = %q, want %q", resp.Title, "Test Announcement")
}
if resp.CreatedBy == nil || *resp.CreatedBy != 1 {
t.Fatalf("created_by = %v, want 1", resp.CreatedBy)
}
}
func TestNotificationsHandleCreateAnnouncement_ValidationError_400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
// Empty title triggers service validation error.
body := `{"title":"","body":"Hello","audience":{"all":true}}`
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
h.HandleCreateAnnouncement(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDeleteAnnouncement_204(t *testing.T) {
store := &fakeNotificationsStore{
announcements: []*notifications.Announcement{
{ID: 5, Title: "To Delete", Audience: notifications.Audience{All: true}},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/5", nil, 1)
req = notifChiRequest(req, "id", "5")
h.HandleDeleteAnnouncement(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDeleteAnnouncement_NotFound_404(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/999", nil, 1)
req = notifChiRequest(req, "id", "999")
h.HandleDeleteAnnouncement(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleListAnnouncements_ReturnsItems(t *testing.T) {
store := &fakeNotificationsStore{
announcements: []*notifications.Announcement{
{ID: 1, Title: "Hello", Audience: notifications.Audience{All: true}},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodGet, "/admin/announcements", nil, 1)
h.HandleListAnnouncements(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Announcement `json:"items"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Items) != 1 {
t.Fatalf("items count = %d, want 1", len(resp.Items))
}
}
// ---------------------------------------------------------------------------
// Tests: ChildSafe profile filtering
// ---------------------------------------------------------------------------
// TestNotificationsList_ChildProfileFiltered asserts that when the profile
// resolver reports IsChild=true, the ListFilter passed to the store has
// ChildSafe=true.
func TestNotificationsList_ChildProfileFiltered(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverAsProfileResolver{isChild: true}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-child")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if !store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = false, want true for child profile")
}
}
// TestNotificationsList_AdultProfileNotFiltered asserts that a non-child
// profile leaves ChildSafe=false in the ListFilter.
func TestNotificationsList_AdultProfileNotFiltered(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverAsProfileResolver{isChild: false}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-adult")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = true, want false for non-child profile")
}
}
// TestNotificationsList_NilResolverDefaultsToFalse asserts that when no
// resolver is wired (nil), ChildSafe remains false.
func TestNotificationsList_NilResolverDefaultsToFalse(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store) // no resolver
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = true, want false when no resolver is wired")
}
}
// TestNotificationsList_ResolverErrorFailsClosed asserts that when the resolver
// returns an error, ChildSafe=true (fail closed).
func TestNotificationsList_ResolverErrorFailsClosed(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverWithError{}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if !store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = false, want true (fail closed on resolver error)")
}
}
// TestNotificationsList_NextCursorSet asserts that when limit=2 and the store
// returns exactly 2 notifications, next_cursor is set to the last item's ID.
func TestNotificationsList_NextCursorSet(t *testing.T) {
now := time.Now().UTC()
store := &fakeNotificationsStore{
list: []*notifications.Notification{
{ID: 12, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
{ID: 11, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications?limit=2", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Items) != 2 {
t.Fatalf("items count = %d, want 2", len(resp.Items))
}
// With limit=2 and 2 items returned, next_cursor should be set to the last item's ID (11).
if resp.NextCursor == nil {
t.Fatal("next_cursor is nil, want 11")
}
if *resp.NextCursor != 11 {
t.Fatalf("next_cursor = %d, want 11", *resp.NextCursor)
}
}
// Suppress unused import.
var _ = strings.Contains
+19 -43
View File
@@ -22,7 +22,6 @@ import (
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
@@ -130,12 +129,11 @@ type PlaybackHandler struct {
RealtimeHub *playback.RealtimeHub
CommandTracker *playback.CommandTracker
CommandDispatcher *playback.CommandDispatcher
// PlaybackConfig returns the current playback config (ffmpeg path,
// hwaccel, transcode dir). Wired to the live config in integrated mode
// so admin changes apply to newly started transcodes. Read it through
// playbackConfig(), which falls back to defaults when unset.
PlaybackConfig func() config.PlaybackConfig
FFmpegLogSink playback.FFmpegLogSink
FFmpegPath string
HWAccel string
HWDevice string
TranscodeDir string
FFmpegLogSink playback.FFmpegLogSink
transcodeMu sync.RWMutex
transcodes map[string]*playback.TranscodeSession
realtimeCommandMu sync.Mutex
@@ -158,6 +156,7 @@ type sessionExpirationHookSetter interface {
func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathResolver) *PlaybackHandler {
h := &PlaybackHandler{
sessionMgr: sessionMgr,
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
transcodes: make(map[string]*playback.TranscodeSession),
realtimeCommands: make(map[string]playbackCommandRecord),
}
@@ -180,19 +179,6 @@ func (h *PlaybackHandler) SetProfileRefreshRequester(requester ProfileRefreshReq
h.profileRefreshRequester = requester
}
// playbackConfig returns the current playback config, falling back to the
// same defaults as config loading (transcode enabled, temp transcode dir)
// when no provider is wired (tests, minimal setups).
func (h *PlaybackHandler) playbackConfig() config.PlaybackConfig {
if h.PlaybackConfig != nil {
return h.PlaybackConfig()
}
return config.PlaybackConfig{
TranscodeEnabled: true,
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
}
}
// CleanupOrphanedTranscodes removes stale per-session temp directories for
// transcodes that are no longer tracked in memory.
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
@@ -203,7 +189,7 @@ func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
}
h.transcodeMu.RUnlock()
return playback.CleanupOrphanedTranscodeDirs(h.playbackConfig().TranscodeDir, active)
return playback.CleanupOrphanedTranscodeDirs(h.TranscodeDir, active)
}
// playbackThresholds reads the playback.watched_threshold and
@@ -628,9 +614,9 @@ func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int {
return directPlayAudioTrackIndex(file)
}
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader, transcodeEnabled bool) playback.AdminSettings {
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader) playback.AdminSettings {
settings := playback.AdminSettings{
TranscodeEnabled: transcodeEnabled,
TranscodeEnabled: true,
}
if repo != nil {
if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" {
@@ -675,7 +661,7 @@ func (h *PlaybackHandler) resolveCapabilityPlaybackSelection(
}
audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex)
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo, h.playbackConfig().TranscodeEnabled)
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo)
method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings)
if requestedFile.Resolution == "2160p" &&
@@ -1444,14 +1430,10 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
// subtitleURLExt returns the URL file extension for a subtitle codec.
// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for
// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side
// bitmap rendering (libpgs); all other text formats get ".vtt".
// client-side rendering (JASSUB); all other text formats get ".vtt".
func subtitleURLExt(codec string) string {
switch {
case playback.IsASS(codec):
if playback.IsASS(codec) {
return ".ass"
case playback.IsPGS(codec):
return ".sup"
}
return ".vtt"
}
@@ -1478,10 +1460,7 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su
embeddedOffset := len(file.ExternalSubtitles)
for i, track := range file.SubtitleTracks {
// PGS bitmap tracks are deliverable as .sup streams for
// client-side rendering; DVD/DVB bitmap tracks still have no
// non-burn-in delivery path, so they stay hidden.
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
if playback.NeedsBurnIn(track.Codec) {
continue
}
@@ -2085,7 +2064,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
TargetCodecAudio: req.TargetCodecAudio,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
HWAccel: h.playbackConfig().HWAccel,
HWAccel: h.HWAccel,
AudioTrackIndex: session.AudioTrackIndex,
SubtitleTrackIndex: req.SubtitleTrackIndex,
SubtitleBurnIn: req.SubtitleBurnIn,
@@ -2151,17 +2130,14 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
"No transcode node is available and local transcode fallback is disabled")
return
}
// Snapshot once so the directory, ffmpeg path, and hwaccel of this
// session stay consistent even if the config reloads mid-start.
playbackCfg := h.playbackConfig()
if err := os.MkdirAll(playbackCfg.TranscodeDir, 0o755); err != nil {
if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
return
}
transcodeSession, err := playback.StartTranscode(context.WithoutCancel(r.Context()), playback.TranscodeOpts{
InputPath: file.FilePath,
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
OutputDir: filepath.Join(h.TranscodeDir, req.SessionID),
SessionID: req.SessionID,
SourceVideoCodec: file.CodecVideo,
SeekSeconds: req.SeekSeconds,
@@ -2171,9 +2147,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
TargetCodecAudio: req.TargetCodecAudio,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
FFmpegPath: playbackCfg.FFmpegPath,
HWAccel: playbackCfg.HWAccel,
HWDevice: playbackCfg.HWDevice,
FFmpegPath: h.FFmpegPath,
HWAccel: h.HWAccel,
HWDevice: h.HWDevice,
AudioTrackIndex: session.AudioTrackIndex,
SubtitleTrackIndex: req.SubtitleTrackIndex,
SubtitleBurnIn: req.SubtitleBurnIn,
@@ -1,81 +0,0 @@
package handlers
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestSubtitleURLExt(t *testing.T) {
cases := []struct {
codec string
want string
}{
{"ass", ".ass"},
{"ssa", ".ass"},
{"pgs", ".sup"},
{"hdmv_pgs_subtitle", ".sup"},
{"subrip", ".vtt"},
{"srt", ".vtt"},
{"", ".vtt"},
}
for _, tc := range cases {
if got := subtitleURLExt(tc.codec); got != tc.want {
t.Errorf("subtitleURLExt(%q) = %q, want %q", tc.codec, got, tc.want)
}
}
}
func TestBuildSubtitleURLs_IncludesPGSButNotOtherBitmaps(t *testing.T) {
file := &models.MediaFile{
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "en", Codec: "subrip"},
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
},
}
urls := buildSubtitleURLs("sess-1", file, nil)
if len(urls) != 2 {
t.Fatalf("expected 2 subtitle URLs (text + PGS), got %d: %+v", len(urls), urls)
}
srt := urls[0]
if srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt" {
t.Errorf("unexpected text track entry: %+v", srt)
}
pgs := urls[1]
if pgs.Codec != "hdmv_pgs_subtitle" {
t.Errorf("expected PGS track to be included, got %+v", pgs)
}
if pgs.URL != "/stream/sess-1/subtitles/1.sup" {
t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL)
}
if pgs.FontBundleURL != "" {
t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL)
}
}
func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) {
file := &models.MediaFile{
ExternalSubtitles: []models.ExternalSubtitle{
{Path: "/media/movie.en.srt", Language: "en", Format: "srt"},
},
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "en", Codec: "pgs"},
},
}
urls := buildSubtitleURLs("sess-2", file, nil)
if len(urls) != 2 {
t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls)
}
pgs := urls[1]
if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup" {
t.Errorf("PGS track index should include the external offset, got %+v", pgs)
}
}
+6 -15
View File
@@ -18,7 +18,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/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -163,7 +162,7 @@ func newPlaybackTestStore(t *testing.T) userstore.UserStore {
func newAuthorizedPlaybackContext() context.Context {
ctx := context.Background()
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
return apimw.SetProfileID(ctx, "profile-1")
}
@@ -183,16 +182,6 @@ func writePlaybackTestFFmpeg(t *testing.T) string {
return path
}
func playbackTestConfig(ffmpegPath, transcodeDir string) func() config.PlaybackConfig {
return func() config.PlaybackConfig {
return config.PlaybackConfig{
FFmpegPath: ffmpegPath,
TranscodeDir: transcodeDir,
TranscodeEnabled: true,
}
}
}
func writePlaybackTestMediaFile(t *testing.T, name string) string {
t.Helper()
@@ -1184,7 +1173,8 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
handler.TranscodeDir = t.TempDir()
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","audio_track_index":1,"codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`))
req = req.WithContext(newAuthorizedPlaybackContext())
@@ -1249,7 +1239,8 @@ func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) {
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
handler.TranscodeDir = t.TempDir()
transcodeReq := httptest.NewRequest(
"POST",
@@ -1412,7 +1403,7 @@ func TestHandleStartTranscode_KeepsSessionWhenStartupFailsForNonMissingReason(t
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.SessionSyncer = syncer
handler.AdminStore = adminStore
handler.PlaybackConfig = playbackTestConfig("", writePlaybackTestMediaFile(t, "occupied-transcode-dir"))
handler.TranscodeDir = writePlaybackTestMediaFile(t, "occupied-transcode-dir")
startReq := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`))
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
+8 -2
View File
@@ -124,6 +124,12 @@ type verifyPINResponse struct {
ExpiresAt string `json:"expires_at,omitempty"`
}
// isAdminProfileRequest reports whether the request user currently holds the
// admin permission, checked server-side against the loaded user.
func (h *ProfileHandler) isAdminProfileRequest(r *http.Request) bool {
return isAdminRequest(r, h.UserRepo)
}
// canManageHouseholdProfiles reports whether the caller may create/update/delete
// profiles belonging to their user. Server admins always can. Otherwise the
// caller's active profile must be the primary profile for the household.
@@ -133,7 +139,7 @@ type verifyPINResponse struct {
// bypass the profile lock by sending only X-Profile-Id.
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
ctx := r.Context()
if apimw.IsAdmin(ctx) {
if h.isAdminProfileRequest(r) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
@@ -311,7 +317,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
// Access-policy fields only make sense when set by a manager on a managed
// profile. On bootstrap the caller is becoming primary themselves, so non-
// admin bootstrap creations must leave those fields at their defaults.
if isBootstrap && !apimw.IsAdmin(r.Context()) &&
if isBootstrap && !h.isAdminProfileRequest(r) &&
(req.IsChild || req.MaxContentRating != "" ||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
req.MaxPlaybackQuality != "") {
+1 -2
View File
@@ -45,7 +45,6 @@ func newAuthorizedProfileRequestWithSession(
req := httptest.NewRequest(method, path, strings.NewReader(body))
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: role,
SessionID: sessionID,
TokenType: auth.TokenTypeAccess,
})
@@ -99,7 +98,7 @@ func TestHandleCreateProfile_EnforcesUserProfileLimit(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
handler.UserRepo = testProfileUserRepo{
user: &models.User{ID: 1, MaxProfiles: 1},
user: &models.User{ID: 1, MaxProfiles: 1, Enabled: true, IsAdmin: true},
}
req := newAuthorizedProfileRequestWithRole(
-234
View File
@@ -1,234 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/push"
)
// maxPushTokenLen bounds a registered push token. APNs/FCM device tokens are
// short; a Web Push subscription JSON (endpoint + keys) is ~1 KB.
const maxPushTokenLen = 8192
// pushRegistry is the minimal interface PushHandler needs from *push.Store.
type pushRegistry interface {
RegisterToken(ctx context.Context, userID int, profileID, deviceID, transport, token string) error
RevokeToken(ctx context.Context, userID int, profileID, deviceID string) error
SetDeviceEnabled(ctx context.Context, userID int, deviceID string, enabled bool) error
ListDevices(ctx context.Context, userID int) ([]push.DeviceInfo, error)
}
// pushConfigReader is the minimal interface PushHandler needs from *push.Config.
type pushConfigReader interface {
WebPush(ctx context.Context) push.WebPushConfig
Status(ctx context.Context) push.Status
}
// pushNotifier is the minimal interface PushHandler needs to send system notifications.
type pushNotifier interface {
CreateSystem(ctx context.Context, userID int, typ, title, body string)
}
// PushHandler handles push notification registration, management, and status endpoints.
type PushHandler struct {
reg pushRegistry
config pushConfigReader
notifier pushNotifier
}
// NewPushHandler creates a new PushHandler. notifier may be nil (test-push endpoint
// returns 503 when nil).
func NewPushHandler(reg pushRegistry, config pushConfigReader, notifier pushNotifier) *PushHandler {
return &PushHandler{reg: reg, config: config, notifier: notifier}
}
// registerRequest is the request body for HandleRegister.
type registerRequest struct {
Transport string `json:"transport"`
Token string `json:"token"`
}
// deviceContext extracts the authenticated user, device id, and active profile
// required by the profile-scoped push device endpoints. It writes a 400 and
// returns ok=false when any of the three is missing.
func (h *PushHandler) deviceContext(w http.ResponseWriter, r *http.Request) (userID int, deviceID, profileID string, ok bool) {
userID = apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Authentication required")
return 0, "", "", false
}
deviceID = strings.TrimSpace(r.Header.Get(deviceIDHeader))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Silo-Device-Id header is required")
return 0, "", "", false
}
profileID = strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return 0, "", "", false
}
return userID, deviceID, profileID, true
}
// HandleRegister handles PUT /notifications/push/device.
// Requires userID (from JWT claims), X-Silo-Device-Id, and X-Profile-Id headers.
// Body: {"transport": "apns"|"fcm"|"webpush", "token": "..."}
// Returns 204 on success.
func (h *PushHandler) HandleRegister(w http.ResponseWriter, r *http.Request) {
userID, deviceID, profileID, ok := h.deviceContext(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
switch req.Transport {
case push.TransportAPNs, push.TransportFCM, push.TransportWebPush:
// valid
default:
writeError(w, http.StatusBadRequest, "bad_request", "transport must be one of apns, fcm, webpush")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "bad_request", "token must not be empty")
return
}
// Cap token length: APNs/FCM device tokens are short; a Web Push subscription
// JSON (endpoint + keys) is ~1 KB. 8 KB is generous and bounds the stored row.
if len(req.Token) > maxPushTokenLen {
writeError(w, http.StatusBadRequest, "bad_request", "token too large")
return
}
if err := h.reg.RegisterToken(r.Context(), userID, profileID, deviceID, req.Transport, req.Token); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRevoke handles DELETE /notifications/push/device.
// Requires userID, X-Silo-Device-Id, and X-Profile-Id headers.
// Returns 204 on success.
func (h *PushHandler) HandleRevoke(w http.ResponseWriter, r *http.Request) {
userID, deviceID, profileID, ok := h.deviceContext(w, r)
if !ok {
return
}
if err := h.reg.RevokeToken(r.Context(), userID, profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke push token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListDevices handles GET /notifications/push/devices.
// Response: {"devices": [...]}
func (h *PushHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
devices, err := h.reg.ListDevices(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
return
}
if devices == nil {
devices = []push.DeviceInfo{}
}
writeJSON(w, http.StatusOK, struct {
Devices []push.DeviceInfo `json:"devices"`
}{Devices: devices})
}
// toggleDeviceRequest is the request body for HandleToggleDevice.
type toggleDeviceRequest struct {
Enabled *bool `json:"enabled"`
}
// HandleToggleDevice handles PUT /notifications/push/devices/{device_id}.
// Body: {"enabled": true|false}
// Returns 204 on success, 404 if device not found.
func (h *PushHandler) HandleToggleDevice(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
deviceID := chi.URLParam(r, "device_id")
var req toggleDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Enabled == nil {
writeError(w, http.StatusBadRequest, "bad_request", "enabled is required")
return
}
if err := h.reg.SetDeviceEnabled(r.Context(), userID, deviceID, *req.Enabled); err != nil {
if errors.Is(err, push.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Device not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleWebPushKey handles GET /notifications/push/webpush-key.
// Response: {"vapid_public_key": "..."} — empty string when not configured.
func (h *PushHandler) HandleWebPushKey(w http.ResponseWriter, r *http.Request) {
cfg := h.config.WebPush(r.Context())
writeJSON(w, http.StatusOK, struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}{VAPIDPublicKey: cfg.VAPIDPublic})
}
// HandleAdminStatus handles GET /admin/push/status.
// Response: {"apns": bool, "fcm": bool, "webpush": bool}
func (h *PushHandler) HandleAdminStatus(w http.ResponseWriter, r *http.Request) {
status := h.config.Status(r.Context())
writeJSON(w, http.StatusOK, status)
}
// HandleGenerateVAPIDKeys handles POST /admin/push/generate-vapid-keys.
// Generates a fresh VAPID key pair and returns it as JSON.
// Response: {"vapid_public": "...", "vapid_private": "..."}
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
// GenerateVAPIDKeys returns (privateKey, publicKey string, err error).
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
return
}
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
}
// HandleSendTestPush handles POST /admin/push/test.
// Sends a system test push notification to the authenticated admin user.
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
if h.notifier == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
return
}
h.notifier.CreateSystem(r.Context(), userID, "system.test_push", "Test push",
"If you can see this on a device, push delivery is working.")
w.WriteHeader(http.StatusAccepted)
}
-496
View File
@@ -1,496 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"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/push"
)
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
type fakePushRegistry struct {
registerErr error
revokeErr error
enableErr error
devices []push.DeviceInfo
// capture last RegisterToken call
lastUserID int
lastProfileID string
lastDeviceID string
lastTransport string
lastToken string
}
func (f *fakePushRegistry) RegisterToken(_ context.Context, userID int, profileID, deviceID, transport, token string) error {
f.lastUserID = userID
f.lastProfileID = profileID
f.lastDeviceID = deviceID
f.lastTransport = transport
f.lastToken = token
return f.registerErr
}
func (f *fakePushRegistry) RevokeToken(_ context.Context, _ int, _, _ string) error {
return f.revokeErr
}
func (f *fakePushRegistry) SetDeviceEnabled(_ context.Context, _ int, _ string, _ bool) error {
return f.enableErr
}
func (f *fakePushRegistry) ListDevices(_ context.Context, _ int) ([]push.DeviceInfo, error) {
return f.devices, nil
}
type fakePushConfig struct {
webPush push.WebPushConfig
status push.Status
}
func (f *fakePushConfig) WebPush(_ context.Context) push.WebPushConfig { return f.webPush }
func (f *fakePushConfig) Status(_ context.Context) push.Status { return f.status }
type fakeNotifier struct {
calls []fakeNotifierCall
}
type fakeNotifierCall struct {
userID int
typ string
title string
body string
}
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, title, body string) {
f.calls = append(f.calls, fakeNotifierCall{userID: userID, typ: typ, title: title, body: body})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// pushUserRequest builds a request with JWT claims (userID) and optionally
// sets the device-id and profile-id headers.
func pushUserRequest(method, target string, body []byte, userID int, profileID, deviceID string) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
if profileID != "" {
ctx = apimw.SetProfileID(ctx, profileID)
}
req = req.WithContext(ctx)
if deviceID != "" {
req.Header.Set(deviceIDHeader, deviceID)
}
return req
}
// pushChiRequest injects a chi URL parameter into the request context.
func pushChiRequest(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// newPushHandler builds a PushHandler backed by the given fakes.
func newPushHandler(reg *fakePushRegistry, cfg *fakePushConfig) *PushHandler {
return NewPushHandler(reg, cfg, nil)
}
// ---------------------------------------------------------------------------
// HandleRegister
// ---------------------------------------------------------------------------
func TestPushHandleRegister_MissingDeviceID_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": "tok"})
// no device header
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_MissingProfileID_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": "tok"})
// device header set but no profile
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_BadTransport_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "smoke-signals", "token": "tok"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_EmptyToken_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": ""})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_HappyPath_Returns204(t *testing.T) {
reg := &fakePushRegistry{}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "fcm", "token": "my-fcm-token"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 42, "prof-99", "dev-abc")
h.HandleRegister(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if reg.lastUserID != 42 {
t.Errorf("userID = %d, want 42", reg.lastUserID)
}
if reg.lastProfileID != "prof-99" {
t.Errorf("profileID = %q, want prof-99", reg.lastProfileID)
}
if reg.lastDeviceID != "dev-abc" {
t.Errorf("deviceID = %q, want dev-abc", reg.lastDeviceID)
}
if reg.lastTransport != "fcm" {
t.Errorf("transport = %q, want fcm", reg.lastTransport)
}
if reg.lastToken != "my-fcm-token" {
t.Errorf("token = %q, want my-fcm-token", reg.lastToken)
}
}
func TestPushHandleRegister_WebPushTransport_Returns204(t *testing.T) {
reg := &fakePushRegistry{}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "webpush", "token": "{}"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
// ---------------------------------------------------------------------------
// HandleRevoke
// ---------------------------------------------------------------------------
func TestPushHandleRevoke_HappyPath_Returns204(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodDelete, "/notifications/push/device", nil, 1, "prof-1", "dev-1")
h.HandleRevoke(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
func TestPushHandleRevoke_MissingDevice_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodDelete, "/notifications/push/device", nil, 1, "prof-1", "")
h.HandleRevoke(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// ---------------------------------------------------------------------------
// HandleListDevices
// ---------------------------------------------------------------------------
func TestPushHandleListDevices_ReturnsDevicesJSON(t *testing.T) {
now := time.Now().UTC()
reg := &fakePushRegistry{
devices: []push.DeviceInfo{
{DeviceID: "dev-1", Name: "iPhone", Platform: "ios", Transport: push.TransportAPNs, PushEnabled: true, RegisteredAt: &now},
{DeviceID: "dev-2", Name: "Chrome", Platform: "web", Transport: push.TransportWebPush, PushEnabled: false},
},
}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/devices", nil, 5, "prof-1", "")
h.HandleListDevices(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
Devices []push.DeviceInfo `json:"devices"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Devices) != 2 {
t.Fatalf("devices count = %d, want 2", len(resp.Devices))
}
if resp.Devices[0].DeviceID != "dev-1" {
t.Errorf("devices[0].DeviceID = %q, want dev-1", resp.Devices[0].DeviceID)
}
}
func TestPushHandleListDevices_EmptySlice_ReturnsEmptyArray(t *testing.T) {
reg := &fakePushRegistry{devices: nil}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/devices", nil, 1, "", "")
h.HandleListDevices(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
// Must produce {"devices":[]} not {"devices":null}
if !containsJSON(body, `"devices":[]`) {
t.Errorf("expected empty array in response, got: %s", body)
}
}
// ---------------------------------------------------------------------------
// HandleToggleDevice
// ---------------------------------------------------------------------------
func TestPushHandleToggleDevice_HappyPath_Returns204(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": true})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-1", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-1")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
func TestPushHandleToggleDevice_NotFound_Returns404(t *testing.T) {
reg := &fakePushRegistry{enableErr: push.ErrNotFound}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": false})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-x", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-x")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestPushHandleToggleDevice_WrappedNotFound_Returns404(t *testing.T) {
reg := &fakePushRegistry{enableErr: errors.Join(errors.New("outer"), push.ErrNotFound)}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": false})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-x", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-x")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// HandleWebPushKey
// ---------------------------------------------------------------------------
func TestPushHandleWebPushKey_ReturnsConfiguredKey(t *testing.T) {
cfg := &fakePushConfig{webPush: push.WebPushConfig{VAPIDPublic: "BPublicKey123", VAPIDPrivate: "priv", Subject: "mailto:a@b.com"}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/webpush-key", nil, 1, "", "")
h.HandleWebPushKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublicKey != "BPublicKey123" {
t.Errorf("vapid_public_key = %q, want BPublicKey123", resp.VAPIDPublicKey)
}
}
func TestPushHandleWebPushKey_Unconfigured_ReturnsEmptyString(t *testing.T) {
cfg := &fakePushConfig{webPush: push.WebPushConfig{}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/webpush-key", nil, 0, "", "")
h.HandleWebPushKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublicKey != "" {
t.Errorf("vapid_public_key = %q, want empty string", resp.VAPIDPublicKey)
}
}
// ---------------------------------------------------------------------------
// HandleAdminStatus
// ---------------------------------------------------------------------------
func TestPushHandleAdminStatus_ReturnsBooleans(t *testing.T) {
cfg := &fakePushConfig{status: push.Status{APNs: true, FCM: false, WebPush: true}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/admin/push/status", nil, 1, "", "")
h.HandleAdminStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp push.Status
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.APNs {
t.Error("apns = false, want true")
}
if resp.FCM {
t.Error("fcm = true, want false")
}
if !resp.WebPush {
t.Error("webpush = false, want true")
}
}
// ---------------------------------------------------------------------------
// HandleGenerateVAPIDKeys
// ---------------------------------------------------------------------------
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/push/generate-vapid-keys", nil)
h.HandleGenerateVAPIDKeys(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
VAPIDPublic string `json:"vapid_public"`
VAPIDPrivate string `json:"vapid_private"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublic == "" {
t.Error("vapid_public is empty")
}
if resp.VAPIDPrivate == "" {
t.Error("vapid_private is empty")
}
if resp.VAPIDPublic == resp.VAPIDPrivate {
t.Errorf("vapid_public and vapid_private are identical: %q", resp.VAPIDPublic)
}
}
// ---------------------------------------------------------------------------
// HandleSendTestPush
// ---------------------------------------------------------------------------
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
n := &fakeNotifier{}
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodPost, "/admin/push/test", nil, 7, "", "")
h.HandleSendTestPush(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body: %s", rec.Code, rec.Body.String())
}
if len(n.calls) != 1 {
t.Fatalf("CreateSystem called %d times, want 1", len(n.calls))
}
call := n.calls[0]
if call.userID != 7 {
t.Errorf("userID = %d, want 7", call.userID)
}
if call.typ != "system.test_push" {
t.Errorf("typ = %q, want system.test_push", call.typ)
}
}
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodPost, "/admin/push/test", nil, 1, "", "")
h.HandleSendTestPush(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return b
}
// containsJSON checks that s contains the substring (after stripping whitespace
// equivalents that json.Marshal doesn't emit — good enough for our payloads).
func containsJSON(s, sub string) bool {
return len(s) > 0 && (len(sub) == 0 || len(s) >= len(sub) && func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
+32 -30
View File
@@ -13,6 +13,7 @@ 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"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
@@ -50,6 +51,7 @@ type RequestService interface {
type RequestsHandler struct {
service RequestService
Users auth.UserLoader // for server-side admin checks
}
func NewRequestsHandler(service RequestService) *RequestsHandler {
@@ -57,7 +59,7 @@ func NewRequestsHandler(service RequestService) *RequestsHandler {
}
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -80,7 +82,7 @@ func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -95,7 +97,7 @@ func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -112,7 +114,7 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R
}
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -127,7 +129,7 @@ func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -142,7 +144,7 @@ func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -157,7 +159,7 @@ func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Reques
}
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -176,7 +178,7 @@ func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -195,7 +197,7 @@ func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Req
}
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -215,7 +217,7 @@ func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -234,7 +236,7 @@ func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -252,7 +254,7 @@ func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -267,7 +269,7 @@ func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -280,7 +282,7 @@ func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -295,7 +297,7 @@ func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -308,7 +310,7 @@ func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -330,7 +332,7 @@ func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -352,7 +354,7 @@ func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -365,7 +367,7 @@ func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
viewer, ok := h.requestViewer(w, r, true)
if !ok {
return
}
@@ -378,7 +380,7 @@ func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -391,7 +393,7 @@ func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -409,7 +411,7 @@ func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Re
}
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -424,7 +426,7 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.
}
func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -442,7 +444,7 @@ func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -461,7 +463,7 @@ func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -473,7 +475,7 @@ func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -496,7 +498,7 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r
}
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -513,7 +515,7 @@ func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
viewer, ok := h.requestViewer(w, r, false)
if !ok {
return
}
@@ -535,7 +537,7 @@ func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusOK, updated)
}
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
claims := apimw.GetClaims(r.Context())
if claims == nil || claims.UserID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
@@ -549,7 +551,7 @@ func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool)
return mediarequests.Viewer{
UserID: claims.UserID,
ProfileID: profileID,
IsAdmin: claims.Role == "admin",
IsAdmin: isAdminRequest(r, h.Users),
}, true
}
-1
View File
@@ -147,7 +147,6 @@ func authedRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
+2 -2
View File
@@ -891,8 +891,8 @@ func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *ht
allowCustom = v == "true"
}
isAdmin := false
if claims := apimw.GetClaims(r.Context()); claims != nil {
isAdmin = claims.Role == "admin"
if h.UserRepo != nil {
isAdmin = isAdminRequest(r, h.UserRepo)
}
for _, o := range req.Overrides {
+4 -4
View File
@@ -452,7 +452,7 @@ func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testin
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Authenticate as a non-admin profile.
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@@ -471,7 +471,7 @@ func TestSaveProfileOverridesUnknownRecipeReturnsBadRequest(t *testing.T) {
body := []byte(`{"scope":"home","library_id":"","overrides":[{"is_user_added":true,"user_section_type":"no_such_recipe","user_config":{}}]}`)
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@@ -495,7 +495,7 @@ func TestSaveProfileOverridesLegacyShapeCannotBypassGate(t *testing.T) {
body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"admin_curated_list","config":{"item_ids":["a"]}}]}`)
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@@ -515,7 +515,7 @@ func TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected(t *testing.T) {
body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"no_such_recipe","config":{}}]}`)
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
+1 -1
View File
@@ -118,7 +118,7 @@ func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest)
if command.Reason == "" {
command.Reason = "server_restart_requested"
}
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
return command, 0, nil, nil
})
+1 -1
View File
@@ -27,7 +27,7 @@ func TestHandleSessionWebSocket_RequiresHelloBeforeRealtimeReady(t *testing.T) {
router := chi.NewRouter()
router.Get("/playback/ws/{session_id}", func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
handler.HandleSessionWebSocket(w, r.WithContext(ctx))
})
+13 -42
View File
@@ -12,7 +12,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/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -32,20 +31,10 @@ type StreamHandler struct {
EventsHub *evt.Hub
AdminStore PlaybackAdminStore
SessionSyncer PlaybackSessionSyncer
// PlaybackConfig returns the current playback config; read it through
// ffmpegPath(). May be nil (tests).
PlaybackConfig func() config.PlaybackConfig
SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles
S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles
S3Bucket string // bucket for subtitle storage
}
// ffmpegPath returns the currently configured ffmpeg binary path.
func (h *StreamHandler) ffmpegPath() string {
if h.PlaybackConfig != nil {
return h.PlaybackConfig().FFmpegPath
}
return ""
FFmpegPath string
SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles
S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles
S3Bucket string // bucket for subtitle storage
}
// NewStreamHandler creates a new StreamHandler backed by the given session
@@ -204,7 +193,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
return
}
vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.ffmpegPath())
vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.FFmpegPath)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error",
"Failed to load external subtitle")
@@ -219,10 +208,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
// Check embedded tracks.
if embeddedIndex < len(file.SubtitleTracks) {
track := file.SubtitleTracks[embeddedIndex]
// PGS is the one bitmap codec we can deliver without burn-in: the
// track is copied losslessly into a .sup stream and rendered
// client-side. DVD/DVB bitmap subs still require burn-in.
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
if playback.NeedsBurnIn(track.Codec) {
writeError(w, http.StatusBadRequest, "bad_request",
"Bitmap subtitle tracks cannot be extracted as text")
return
@@ -338,7 +324,7 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque
return
}
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.ffmpegPath())
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.FFmpegPath)
if err != nil {
slog.WarnContext(r.Context(), "subtitle font extraction failed",
"file_id", file.ID,
@@ -420,24 +406,12 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session) {
track := file.SubtitleTracks[embeddedIndex]
outFormat := "vtt"
switch {
case playback.IsASS(track.Codec):
if playback.IsASS(track.Codec) {
outFormat = "ass"
case playback.IsPGS(track.Codec):
outFormat = "sup"
}
// ASS and PGS are fetched exactly once and consumed whole by their
// client-side renderers (JASSUB / libpgs), so they must never be
// windowed. Note subtitleSeekPosition falls back to the session's
// last reported position even without a ?position= query — relying
// on StreamExtractSubtitle's codec guard alone would still log a
// misleading nonzero seek here.
var seek, duration float64
if outFormat == "vtt" {
seek = subtitleSeekPosition(r, session)
duration = subtitleWindowDuration(r)
}
seek := subtitleSeekPosition(r, session)
duration := subtitleWindowDuration(r)
slog.InfoContext(r.Context(), "subtitle stream requested",
"file_id", file.ID,
"embedded_index", embeddedIndex,
@@ -448,12 +422,9 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
"duration_seconds", duration,
)
switch outFormat {
case "ass":
if outFormat == "ass" {
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
case "sup":
w.Header().Set("Content-Type", "application/octet-stream")
default:
} else {
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
}
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -466,7 +437,7 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
SourceCodec: track.Codec,
SeekSeconds: seek,
DurationSeconds: duration,
FFmpegPath: h.ffmpegPath(),
FFmpegPath: h.FFmpegPath,
Writer: w,
})
if err != nil {
+5 -1
View File
@@ -11,6 +11,7 @@ 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/catalog"
"github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
@@ -26,6 +27,9 @@ type SubtitleAIHandler struct {
// StoreProvider resolves household profiles for the transcription quota
// exemption check; when nil the whole admin account is exempt.
StoreProvider userstore.UserStoreProvider
// Users loads the account server-side for the quota exemption's admin
// check; admin status is never read from token contents.
Users auth.UserLoader
}
// NewSubtitleAIHandler creates a handler backed by the given service.
@@ -154,7 +158,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
// budgeting gate, not a security boundary; the quota itself is account-scoped.
func (h *SubtitleAIHandler) quotaExempt(r *http.Request) bool {
ctx := r.Context()
if !apimw.IsAdmin(ctx) {
if !isAdminRequest(r, h.Users) {
return false
}
profileID := apimw.GetProfileID(ctx)
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"strings"
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/subtitles"
"github.com/go-chi/chi/v5"
)
@@ -63,6 +64,7 @@ type SubtitleSearchHandler struct {
repo subtitles.Repository
mediaResolver SubtitleMediaResolver
FileAuthorizer *MediaFileAuthorizer
Users auth.UserLoader // for server-side admin checks
}
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
@@ -334,8 +336,8 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
}
claims := apimw.GetClaims(r.Context())
isAdmin := claims != nil && claims.Role == "admin"
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
isAdmin := !isOwner && isAdminRequest(r, h.Users)
if !isAdmin && !isOwner {
writeError(w, http.StatusForbidden, "forbidden", "Not authorized to delete this subtitle")
return
@@ -56,7 +56,6 @@ func newSubtitleAuthRequest(method, path string, body io.Reader) *http.Request {
req := httptest.NewRequest(method, path, body)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
+28 -23
View File
@@ -19,6 +19,9 @@ type contextKey string
// claimsKey is the context key for storing JWT claims.
const claimsKey contextKey = "claims"
// userKey is the context key for the loaded authenticated user.
const userKey contextKey = "user"
// SessionValidator checks whether a session is still valid (not revoked/expired).
type SessionValidator interface {
IsValid(ctx context.Context, sessionID string) (bool, error)
@@ -35,28 +38,25 @@ type APIKeyValidator interface {
UpdateLastUsed(ctx context.Context, id int64) error
}
// APIKeyUserLoader loads a user by ID for API key authentication.
type APIKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// AuthMiddleware provides HTTP middleware for JWT-based authentication with
// session validity caching.
type AuthMiddleware struct {
tokenValidator TokenValidator
sessionValidator SessionValidator
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader auth.UserLoader // nil if API keys not configured
userLoader auth.UserLoader // nil if no user store; RequireAdmin then denies
}
// NewAuthMiddleware creates a new AuthMiddleware with the given token validator
// and session validator.
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware {
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul auth.UserLoader, ul auth.UserLoader) *AuthMiddleware {
return &AuthMiddleware{
tokenValidator: tv,
sessionValidator: sv,
apiKeyValidator: akv,
apiKeyUserLoader: akul,
userLoader: ul,
}
}
@@ -105,7 +105,6 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
claims = &auth.Claims{
UserID: user.ID,
Role: user.Role,
SessionID: "",
TokenType: auth.TokenTypeAPIKey,
APIKeyID: apiKey.ID,
@@ -144,23 +143,28 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
})
}
// RequireAdmin is a standalone HTTP middleware that checks if the authenticated
// user has the "admin" role. It expects RequireAuth to have already placed
// claims in the request context.
func RequireAdmin(next http.Handler) http.Handler {
// RequireAdmin enforces that the authenticated user currently holds the
// admin permission. The check is server-side against group-derived policy —
// never against token contents — so revoking admin takes effect on the next
// request. The loaded user is stashed in the context for handlers.
func (am *AuthMiddleware) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role != "admin" {
if am.userLoader == nil {
writeForbidden(w, "Admin access required")
return
}
next.ServeHTTP(w, r)
user, err := am.userLoader.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled || !user.IsAdmin {
writeForbidden(w, "Admin access required")
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -181,12 +185,13 @@ func GetClaims(ctx context.Context) *auth.Claims {
return claims
}
// IsAdmin reports whether the context's authenticated user account has the
// admin role. Returns false when no claims are present. Note this is the
// account-level role; it says nothing about which household profile is active.
func IsAdmin(ctx context.Context) bool {
claims := GetClaims(ctx)
return claims != nil && claims.Role == "admin"
// GetUser returns the user loaded by RequireAdmin, or nil.
func GetUser(ctx context.Context) *models.User {
user, ok := ctx.Value(userKey).(*models.User)
if !ok {
return nil
}
return user
}
// GetUserID retrieves the user ID from the JWT claims in the context.
+125
View File
@@ -0,0 +1,125 @@
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeAdminUserLoader implements auth.UserLoader for unit tests.
type fakeAdminUserLoader struct {
user *models.User
err error
}
func (f *fakeAdminUserLoader) GetByID(_ context.Context, _ int) (*models.User, error) {
return f.user, f.err
}
// newTestAuthMiddleware returns an AuthMiddleware wired only with a userLoader;
// the JWT/session fields are unused by RequireAdmin.
func newTestAuthMiddleware(ul auth.UserLoader) *AuthMiddleware {
return NewAuthMiddleware(nil, nil, nil, nil, ul)
}
// adminClaims injects a minimal Claims into the given request context.
func adminClaims(r *http.Request, userID int) *http.Request {
ctx := SetClaims(r.Context(), &auth.Claims{UserID: userID, TokenType: auth.TokenTypeAccess})
return r.WithContext(ctx)
}
func TestRequireAdmin(t *testing.T) {
tests := []struct {
name string
setupReq func(*http.Request) *http.Request
loader auth.UserLoader
wantStatus int
checkUser bool // if true, assert GetUser is non-nil inside the next handler
}{
{
name: "no claims in context returns 401",
setupReq: func(r *http.Request) *http.Request { return r }, // no claims injected
loader: &fakeAdminUserLoader{user: &models.User{ID: 1, Enabled: true, IsAdmin: true}},
wantStatus: http.StatusUnauthorized,
},
{
name: "nil user loader returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
loader: nil,
wantStatus: http.StatusForbidden,
},
{
name: "loader returns error returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
loader: &fakeAdminUserLoader{err: errors.New("db error")},
wantStatus: http.StatusForbidden,
},
{
name: "loader returns nil user returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
loader: &fakeAdminUserLoader{user: nil, err: nil},
wantStatus: http.StatusForbidden,
},
{
name: "disabled admin user returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 2) },
loader: &fakeAdminUserLoader{
user: &models.User{ID: 2, Enabled: false, IsAdmin: true},
},
wantStatus: http.StatusForbidden,
},
{
name: "enabled non-admin user returns 403",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 3) },
loader: &fakeAdminUserLoader{
user: &models.User{ID: 3, Enabled: true, IsAdmin: false},
},
wantStatus: http.StatusForbidden,
},
{
name: "enabled admin reaches next handler and user is stashed in context",
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 4) },
loader: &fakeAdminUserLoader{
user: &models.User{ID: 4, Enabled: true, IsAdmin: true},
},
wantStatus: http.StatusNoContent,
checkUser: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
am := newTestAuthMiddleware(tc.loader)
var gotUser *models.User
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser = GetUser(r.Context())
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/admin/something", nil)
req = tc.setupReq(req)
rec := httptest.NewRecorder()
am.RequireAdmin(next).ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
if tc.checkUser {
if gotUser == nil {
t.Fatal("GetUser returned nil inside next handler, want a non-nil user")
}
if gotUser.ID != tc.loader.(*fakeAdminUserLoader).user.ID {
t.Fatalf("stashed user ID = %d, want %d", gotUser.ID, tc.loader.(*fakeAdminUserLoader).user.ID)
}
}
})
}
}
+44 -12
View File
@@ -4,6 +4,8 @@ import (
"context"
"net/http"
"strings"
"github.com/Silo-Server/silo-server/internal/auth"
)
// DemoSettingsReader is the subset of ServerSettingsStore needed by DemoGuard.
@@ -20,11 +22,12 @@ type DemoSettingsReader interface {
// Blocked: API key management, downloads, history imports, subtitle downloads.
type DemoGuard struct {
settings DemoSettingsReader
users auth.UserLoader // nil means no admin bypass
}
// NewDemoGuard creates a new DemoGuard.
func NewDemoGuard(settings DemoSettingsReader) *DemoGuard {
return &DemoGuard{settings: settings}
func NewDemoGuard(settings DemoSettingsReader, users auth.UserLoader) *DemoGuard {
return &DemoGuard{settings: settings, users: users}
}
// blockedRoute defines a method + path prefix combination that is blocked in demo mode.
@@ -59,14 +62,8 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
return
}
// Admins bypass all demo restrictions.
claims := GetClaims(r.Context())
if claims != nil && claims.Role == "admin" {
next.ServeHTTP(w, r)
return
}
// Check if this request matches a blocked route.
blocked := false
path := r.URL.Path
for _, br := range demoBlockedRoutes {
if !strings.HasPrefix(path, br.prefix) {
@@ -74,16 +71,51 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
}
for _, m := range br.methods {
if r.Method == m {
writeDemoBlocked(w)
return
blocked = true
break
}
}
if blocked {
break
}
}
if !blocked {
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
// Admins bypass all demo restrictions. Admin status is checked against
// the loaded user (group-derived), never against token contents.
if dg.isAdmin(r) {
next.ServeHTTP(w, r)
return
}
writeDemoBlocked(w)
})
}
// isAdmin reports whether the request's authenticated user currently holds
// the admin permission. It first consults the user stashed in the context by
// RequireAdmin and falls back to a fresh load.
func (dg *DemoGuard) isAdmin(r *http.Request) bool {
if user := GetUser(r.Context()); user != nil {
return user.Enabled && user.IsAdmin
}
if dg.users == nil {
return false
}
claims := GetClaims(r.Context())
if claims == nil {
return false
}
user, err := dg.users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil {
return false
}
return user.Enabled && user.IsAdmin
}
func writeDemoBlocked(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
+11 -9
View File
@@ -40,26 +40,28 @@ func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler)
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role == "admin" {
next.ServeHTTP(w, r)
return
}
if m == nil || m.users == nil || m.libraries == nil {
writeForbidden(w, "Metadata curation permission required")
return
}
user, err := m.users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled {
writeForbidden(w, "Metadata curation permission required")
return
}
// Admins bypass the per-library restriction checks below.
if user.IsAdmin {
next.ServeHTTP(w, r)
return
}
contentID := chi.URLParam(r, "id")
if contentID == "" {
writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
user, err := m.users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled {
writeForbidden(w, "Metadata curation permission required")
return
}
if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) {
writeForbidden(w, "Metadata curation permission required")
return
+15 -15
View File
@@ -30,16 +30,16 @@ func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Conte
return f.ids, f.err
}
func requestWithItemID(role string) *http.Request {
func requestWithItemID() *http.Request {
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil)
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, TokenType: auth.TokenTypeAccess})
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess})
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", "item-1")
ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
return req.WithContext(ctx)
}
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int {
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int) int {
mw := NewPermissionMiddleware(
fakePermissionUserLoader{user: user},
fakeTargetLibraryResolver{ids: libraryIDs},
@@ -48,52 +48,52 @@ func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role str
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
next.ServeHTTP(rec, requestWithItemID(role))
next.ServeHTTP(rec, requestWithItemID())
return rec.Code
}
func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) {
code := runMetadataCurationMiddleware(nil, nil, "admin")
code := runMetadataCurationMiddleware(&models.User{ID: 7, Enabled: true, IsAdmin: true}, nil)
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
code := runMetadataCurationMiddleware(user, []int{1}, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
code := runMetadataCurationMiddleware(user, []int{1})
if code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
}
}
func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2})
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 3}, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 3})
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2})
if code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
}
}
func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) {
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, nil, "user")
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, nil)
if code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", code, http.StatusNotFound)
}
+6
View File
@@ -46,6 +46,12 @@ func (m *ViewerAccessMiddleware) RequireViewerAccess(next http.Handler) http.Han
scope, err := m.resolver.Resolve(r.Context(), input)
if err != nil {
switch {
case errors.Is(err, access.ErrUserDisabled):
// Disabling a user also revokes their sessions, so RequireAuth
// normally rejects first; this covers any session that is
// still live (e.g. the account was disabled out of band).
writeUnauthorized(w, "User account is disabled")
return
case errors.Is(err, access.ErrProfileUnverified):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
+99 -219
View File
@@ -48,8 +48,6 @@ import (
"github.com/Silo-Server/silo-server/internal/opslog"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/push"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
@@ -75,13 +73,7 @@ import (
// Dependencies holds all shared dependencies that handlers need.
type Dependencies struct {
Config *config.Config
// LiveConfig returns the current hot-reloaded config. May be nil (tests,
// worker modes); read through CurrentConfig(), which falls back to Config.
LiveConfig func() *config.Config
// OnConfigChange registers a callback fired after a live config reload
// actually changes the config. May be nil when hot reload is not wired.
OnConfigChange func(fn func(old, updated *config.Config))
Config *config.Config
BootstrapSensitiveConfigured map[string]bool
BootstrapSensitiveValues map[string]string
AppContext context.Context
@@ -177,23 +169,6 @@ type Dependencies struct {
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
// resolve correctly. May be nil; no ABS routes are registered in that case.
ABSHandler absHandler
// NotificationsService is the notifications domain service used to power
// the inbox, preferences, and announcement endpoints. May be nil; routes
// are not registered in that case.
NotificationsService *notifications.Service
// PushStore is the push token/device registry. May be nil; push routes
// are not registered in that case.
PushStore *push.Store
// PushConfig reads push provider configuration from the settings repo.
// May be nil; push routes are not registered when PushStore is nil.
PushConfig *push.Config
// PresenceRegistry tracks live WebSocket connections for presence-aware
// push suppression. May be nil; a no-op fallback is used in that case.
PresenceRegistry presence.Registry
}
// absHandler is the narrow interface the router needs from the ABS handler.
@@ -202,17 +177,6 @@ type absHandler interface {
Mount(r chi.Router)
}
// CurrentConfig returns the live config when hot reload is wired, falling
// back to the startup snapshot otherwise.
func (d *Dependencies) CurrentConfig() *config.Config {
if d.LiveConfig != nil {
if cfg := d.LiveConfig(); cfg != nil {
return cfg
}
}
return d.Config
}
// NewRouter creates a chi.Router with all middleware and routes mounted
// under /api/v1/. ABS-compat routes (/abs/*, /login, /socket.io/*) are
// mounted at the root level when deps.ABSHandler is non-nil.
@@ -298,12 +262,6 @@ func NewRouter(deps Dependencies) chi.Router {
deps.Config.Auth.AccessTokenExpiry,
deps.Config.Auth.RefreshTokenExpiry,
)
if deps.OnConfigChange != nil {
jwtForReload := jwtService
deps.OnConfigChange(func(_, updated *config.Config) {
jwtForReload.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
})
}
provider := auth.NewLocalProvider(userRepo, sessionRepo)
authService = auth.NewService(
provider,
@@ -319,7 +277,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
deviceLoginService = auth.NewDeviceLoginService(deps.DB, userRepo, jwtService, sessionRepo)
authHandler = handlers.NewAuthHandler(authService, jwtService, deviceLoginService)
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo)
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo, userRepo)
profileTokenService = access.NewProfileTokenService(deps.Config.Auth.JWTSecret, 0)
if deps.UserStoreProvider != nil {
viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService)
@@ -348,7 +306,11 @@ func NewRouter(deps Dependencies) chi.Router {
// Build demo guard middleware if server settings are available.
var demoGuard *apimw.DemoGuard
if settingsRepo != nil {
demoGuard = apimw.NewDemoGuard(settingsRepo)
var demoUsers auth.UserLoader
if userRepo != nil {
demoUsers = userRepo
}
demoGuard = apimw.NewDemoGuard(settingsRepo, demoUsers)
}
// Build library handler if folder repo is available.
@@ -516,12 +478,14 @@ func NewRouter(deps Dependencies) chi.Router {
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
AttachRequestRouter(requestSvc, deps.PluginService)
requestSvc.SetEventsHub(deps.EventsHub)
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
if viewerResolver != nil {
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
}
requestHandler = handlers.NewRequestsHandler(requestSvc)
if userRepo != nil {
requestHandler.Users = userRepo
}
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && deps.PluginService != nil {
@@ -706,13 +670,13 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.JWTSecret = deps.Config.Auth.JWTSecret
}
if deps.Config != nil {
playbackHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
playbackHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
playbackHandler.HWAccel = deps.Config.Playback.HWAccel
playbackHandler.TranscodeDir = deps.Config.Playback.TranscodeDir
if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil {
slog.Warn("playback transcode cleanup failed", "dir", deps.Config.Playback.TranscodeDir, "error", err)
slog.Warn("playback transcode cleanup failed", "dir", playbackHandler.TranscodeDir, "error", err)
} else if cleaned > 0 {
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", deps.Config.Playback.TranscodeDir, "count", cleaned)
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", playbackHandler.TranscodeDir, "count", cleaned)
}
}
playbackHandler.ProbeEnsurer = deps.ProbeEnsurer
@@ -775,17 +739,19 @@ func NewRouter(deps Dependencies) chi.Router {
streamHandler.S3Bucket = deps.S3Public.Bucket()
}
if streamHandler != nil && deps.Config != nil {
streamHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
}
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
// Build admin handler if we have a user repo.
var adminHandler *handlers.AdminHandler
var adminGroupsHandler *handlers.AdminGroupsHandler
var catalogSeedHandler *handlers.CatalogSeedHandler
var adminJobsHandler *handlers.AdminJobsHandler
if deps.DB != nil {
adminGroupsHandler = handlers.NewAdminGroupsHandler(auth.NewGroupRepository(deps.DB))
}
if userRepo != nil {
adminHandler = handlers.NewAdminHandler(userRepo, deps.DB, deps.UserStoreProvider)
adminHandler.SessionsLoader = playbackSessionsLoader
@@ -819,6 +785,9 @@ func NewRouter(deps Dependencies) chi.Router {
adminJobsHandler = handlers.NewAdminJobsHandler(jobRepo, privateStore)
adminJobsHandler.CancelRegistry = deps.AdminJobCancelRegistry
adminJobsHandler.RealtimeHub = deps.RealtimeHub
if userRepo != nil {
adminJobsHandler.Users = userRepo
}
if adminHandler != nil && deps.FolderRepo != nil && deps.FileRepo != nil && itemRepo != nil && episodeRepo != nil {
adminHandler.JobRepo = jobRepo
adminHandler.ItemRefreshResolver = adminjob.NewItemRefreshResolver(
@@ -948,6 +917,9 @@ func NewRouter(deps Dependencies) chi.Router {
mediaResolver := &pgSubtitleMediaResolver{pool: deps.DB}
subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver)
if userRepo != nil {
subtitleSearchHandler.Users = userRepo
}
}
if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil {
@@ -959,40 +931,58 @@ func NewRouter(deps Dependencies) chi.Router {
// the existing subtitle pipeline with no client changes.
// Shared AI endpoint client + dispatch semaphore: subtitle translation/ASR
// and metadata translation draw from one client and one concurrency bound.
// Connection settings, models, toggles, and quotas hot-reload through
// OnConfigChange; only the semaphore size (ai.max_concurrent_jobs) is
// fixed at construction.
var aiClient *llm.Client
var aiSem chan struct{}
if deps.Config != nil {
aiClient = llm.NewClient(llmConfigFromServer(deps.Config))
aiClient = llm.NewClient(llm.Config{
BaseURL: deps.Config.AI.BaseURL,
APIKey: deps.Config.AI.APIKey,
ChatModel: deps.Config.AI.ChatModel,
ASRBaseURL: deps.Config.AI.ASRBaseURL,
ASRAPIKey: deps.Config.AI.ASRAPIKey,
ASRModel: deps.Config.AI.ASRModel,
})
aiSem = jobrunner.NewSemaphore(deps.Config.AI.MaxConcurrentJobs)
if deps.OnConfigChange != nil {
clientForReload := aiClient
deps.OnConfigChange(func(_, updated *config.Config) {
clientForReload.UpdateConfig(llmConfigFromServer(updated))
})
}
}
var subtitleAIHandler *handlers.SubtitleAIHandler
if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil {
aiCfg, disabledGateway := effectiveSubtitleAIConfig(deps.Config)
if disabledGateway != "" {
warnChatOnlyGateway(disabledGateway)
// A chat-only gateway (e.g. OpenRouter) cannot produce timestamped
// transcriptions; disable ASR rather than let every job fail. The
// settings API rejects such values for the ASR URL, but the chat base
// URL legitimately may be one — this catches the blank-ASR-URL
// fallback case.
transcribeEnabled := deps.Config.SubtitleAI.TranscribeEnabled
effectiveASRBase := deps.Config.AI.ASRBaseURL
if effectiveASRBase == "" {
effectiveASRBase = deps.Config.AI.BaseURL
}
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", effectiveASRBase)
transcribeEnabled = false
}
aiCfg := subtitleai.Config{
Configured: deps.Config.AI.BaseURL != "",
TranslateEnabled: deps.Config.SubtitleAI.Enabled,
TranscribeEnabled: transcribeEnabled,
ChatModel: deps.Config.AI.ChatModel,
ASRModel: deps.Config.AI.ASRModel,
BatchSize: deps.Config.SubtitleAI.BatchSize,
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
TranscribeQuotaJobs: deps.Config.SubtitleAI.TranscribeQuotaJobs,
TranscribeQuotaPeriod: deps.Config.SubtitleAI.TranscribeQuotaPeriod,
}
var aiNotifier subtitleai.Notifier
if subtitleAINotifier != nil {
aiNotifier = subtitleAINotifier
}
aiTranslator := subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors)
aiTranscriber := subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds)
aiService := subtitleai.NewService(
deps.AppContext,
aiCfg,
subtitleai.NewPgJobRepository(deps.DB),
aiTranslator,
aiTranscriber,
subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors),
subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds),
subtitleManager,
subtitleRepo,
deps.FileRepo,
@@ -1002,23 +992,9 @@ func NewRouter(deps Dependencies) chi.Router {
aiSem,
)
aiService.Recover()
if deps.OnConfigChange != nil {
deps.OnConfigChange(func(old, updated *config.Config) {
newCfg, newDisabled := effectiveSubtitleAIConfig(updated)
aiService.UpdateConfig(newCfg)
aiTranslator.SetBatching(updated.SubtitleAI.BatchSize, updated.SubtitleAI.ContextNeighbors)
aiTranscriber.SetExtraction(updated.Playback.FFmpegPath, updated.SubtitleAI.ASRChunkSeconds)
// Warn only when the gateway-disable condition newly appears,
// not on every unrelated settings change.
if newDisabled != "" && old != nil {
if _, oldDisabled := effectiveSubtitleAIConfig(old); oldDisabled == "" {
warnChatOnlyGateway(newDisabled)
}
}
})
}
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
subtitleAIHandler.StoreProvider = deps.UserStoreProvider
subtitleAIHandler.Users = userRepo
}
// Metadata AI translation (descriptions into the localization tables).
@@ -1027,7 +1003,12 @@ func NewRouter(deps Dependencies) chi.Router {
mtRepo := metadatatranslation.NewPgRepository(deps.DB)
mtService := metadatatranslation.NewService(
deps.AppContext,
metadataAIConfigFromServer(deps.Config),
metadatatranslation.Config{
Enabled: deps.Config.MetadataAI.Enabled,
Configured: deps.Config.AI.BaseURL != "",
ChatModel: deps.Config.AI.ChatModel,
OnView: deps.Config.MetadataAI.OnView,
},
mtRepo,
mtRepo,
&metadatatranslation.CatalogLocalizationStore{
@@ -1040,11 +1021,6 @@ func NewRouter(deps Dependencies) chi.Router {
slog.Default(),
)
mtService.Recover()
if deps.OnConfigChange != nil {
deps.OnConfigChange(func(_, updated *config.Config) {
mtService.UpdateConfig(metadataAIConfigFromServer(updated))
})
}
metadataAIHandler = handlers.NewMetadataAIHandler(mtService)
// Wire the refresh fallback: libraries with auto_translate_metadata get
// missing localizations filled after each metadata refresh.
@@ -1355,7 +1331,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(authMiddleware.RequireAuth)
r.Get("/theme/catalog", themeHandler.HandleCatalog)
r.Get("/theme/download", themeHandler.HandleDownload)
r.With(apimw.RequireAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
r.With(authMiddleware.RequireAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
})
}
}
@@ -1382,7 +1358,7 @@ func NewRouter(deps Dependencies) chi.Router {
http.NotFound(w, r)
return
}
authenticated, admin := resolveOptionalPluginAccess(r, jwtService, sessionRepo)
authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, apiKeyRepo, userRepo)
deps.PluginHTTPProxy.ServeAsset(w, r.WithContext(plugins.WithPluginAccess(r.Context(), authenticated, admin)), installationID, assetPath)
})
}
@@ -1513,8 +1489,7 @@ func NewRouter(deps Dependencies) chi.Router {
deps.ScanRegistry,
deps.LibraryScanQueue,
historyImportSvc,
deps.NotificationsService,
deps.PresenceRegistry,
userRepo,
)
r.Get("/events/ws", eventsHandler.HandleWebSocket)
}
@@ -1537,7 +1512,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Library management routes (admin-only).
if libraryHandler != nil {
r.Group(func(r chi.Router) {
r.Use(apimw.RequireAdmin)
r.Use(authMiddleware.RequireAdmin)
r.Route("/libraries", func(r chi.Router) {
r.Get("/", libraryHandler.HandleListLibraries)
@@ -1663,36 +1638,6 @@ func NewRouter(deps Dependencies) chi.Router {
}
}
// Notifications inbox and preferences (profile-scoped).
if deps.NotificationsService != nil {
notificationsHandler := handlers.NewNotificationsHandler(deps.NotificationsService, deps.UserStoreProvider)
r.Route("/notifications", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", notificationsHandler.HandleList)
r.Get("/unread-count", notificationsHandler.HandleUnreadCount)
r.Post("/read", notificationsHandler.HandleMarkRead)
r.Post("/{id}/dismiss", notificationsHandler.HandleDismiss)
r.Get("/preferences", notificationsHandler.HandleGetPreferences)
r.Put("/preferences", notificationsHandler.HandlePutPreferences)
})
}
// Push registration, device management, and VAPID key.
// Register and revoke are profile-scoped: they store a profileID alongside the
// push token so that notifications are delivered to the correct profile.
// RequireProfile is applied per-route (not via r.Use) to leave the three
// user-scoped endpoints (devices list, toggle, webpush-key) without it.
if deps.PushStore != nil && deps.PushConfig != nil {
pushHandler := handlers.NewPushHandler(deps.PushStore, deps.PushConfig, deps.NotificationsService)
r.Route("/notifications/push", func(r chi.Router) {
r.With(apimw.RequireProfile).Put("/device", pushHandler.HandleRegister)
r.With(apimw.RequireProfile).Delete("/device", pushHandler.HandleRevoke)
r.Get("/devices", pushHandler.HandleListDevices)
r.Put("/devices/{device_id}", pushHandler.HandleToggleDevice)
r.Get("/webpush-key", pushHandler.HandleWebPushKey)
})
}
// Progress and sync routes (profile-scoped).
if progressHandler != nil {
r.Route("/progress", func(r chi.Router) {
@@ -2079,7 +2024,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Admin routes.
if adminHandler != nil {
r.Route("/admin", func(r chi.Router) {
metadataItemAccess := apimw.RequireAdmin
metadataItemAccess := authMiddleware.RequireAdmin
if permissionMiddleware != nil {
metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem
}
@@ -2106,7 +2051,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
r.Group(func(r chi.Router) {
r.Use(apimw.RequireAdmin)
r.Use(authMiddleware.RequireAdmin)
r.Get("/users", adminHandler.HandleListUsers)
r.Post("/users", adminHandler.HandleCreateUser)
@@ -2128,6 +2073,17 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/devices", adminHandler.HandleListDevices)
r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice)
if adminGroupsHandler != nil {
r.Get("/groups", adminGroupsHandler.HandleList)
r.Post("/groups", adminGroupsHandler.HandleCreate)
r.Get("/groups/{id}", adminGroupsHandler.HandleGet)
r.Patch("/groups/{id}", adminGroupsHandler.HandleUpdate)
r.Delete("/groups/{id}", adminGroupsHandler.HandleDelete)
r.Get("/groups/{id}/members", adminGroupsHandler.HandleListMembers)
r.Put("/groups/{id}/members/{userID}", adminGroupsHandler.HandleAddMember)
r.Delete("/groups/{id}/members/{userID}", adminGroupsHandler.HandleRemoveMember)
}
r.Get("/sessions", adminHandler.HandleListSessions)
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
@@ -2447,22 +2403,6 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
}
if deps.NotificationsService != nil {
notificationsHandler := handlers.NewNotificationsHandler(deps.NotificationsService, deps.UserStoreProvider)
r.Route("/announcements", func(r chi.Router) {
r.Get("/", notificationsHandler.HandleListAnnouncements)
r.Post("/", notificationsHandler.HandleCreateAnnouncement)
r.Delete("/{id}", notificationsHandler.HandleDeleteAnnouncement)
})
}
if deps.PushStore != nil && deps.PushConfig != nil {
pushHandler := handlers.NewPushHandler(deps.PushStore, deps.PushConfig, deps.NotificationsService)
r.Get("/push/status", pushHandler.HandleAdminStatus)
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
r.Post("/push/test", pushHandler.HandleSendTestPush)
}
if adminPlaybackControlHandler != nil {
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
@@ -2543,18 +2483,11 @@ func (r *pgSubtitleMediaResolver) GetMediaFileWithMetadata(ctx context.Context,
return &meta, nil
}
func resolveOptionalPluginAccess(
r *http.Request,
jwtService *auth.JWTService,
sessionRepo *auth.SessionRepository,
) (bool, bool) {
authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, nil, nil)
return authenticated, admin
}
// resolveOptionalPluginAccessUser is like resolveOptionalPluginAccess but also
// returns the authenticated user's ID, and accepts API-key bearer tokens
// (sa_*) when apiKeyRepo + userRepo are provided.
// resolveOptionalPluginAccessUser resolves the optionally authenticated user
// for plugin HTTP routes and assets. It accepts API-key bearer tokens (sa_*)
// when apiKeyRepo + userRepo are provided. Admin status is always derived from
// the freshly loaded user, never from token contents; when userRepo is nil the
// caller is treated as non-admin.
func resolveOptionalPluginAccessUser(
r *http.Request,
jwtService *auth.JWTService,
@@ -2597,7 +2530,7 @@ func resolveOptionalPluginAccessUser(
if err != nil || !user.Enabled {
return false, false, 0
}
return true, user.Role == "admin", user.ID
return true, user.IsAdmin, user.ID
}
claims, err := jwtService.ValidateToken(token)
@@ -2608,7 +2541,14 @@ func resolveOptionalPluginAccessUser(
if err != nil || !valid {
return false, false, 0
}
return true, claims.Role == "admin", claims.UserID
if userRepo == nil {
return true, false, claims.UserID
}
user, err := userRepo.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled {
return false, false, 0
}
return true, user.IsAdmin, user.ID
}
// NewTMDBCollectionFetcher creates a TMDBCollectionFetcher from an API key.
@@ -2751,63 +2691,3 @@ func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset
}
return entries, nil
}
// llmConfigFromServer derives the shared AI client config from the server
// config. Used at construction and again on every config reload.
func llmConfigFromServer(cfg *config.Config) llm.Config {
return llm.Config{
BaseURL: cfg.AI.BaseURL,
APIKey: cfg.AI.APIKey,
ChatModel: cfg.AI.ChatModel,
ASRBaseURL: cfg.AI.ASRBaseURL,
ASRAPIKey: cfg.AI.ASRAPIKey,
ASRModel: cfg.AI.ASRModel,
}
}
// effectiveSubtitleAIConfig derives the subtitle AI service config from the
// server config. A chat-only gateway (e.g. OpenRouter) cannot produce
// timestamped transcriptions, so transcription is disabled rather than
// letting every job fail; the settings API rejects such values for the ASR
// URL, but the chat base URL legitimately may be one — this catches the
// blank-ASR-URL fallback case. The second return is the offending endpoint
// when that guard fired, empty otherwise.
func effectiveSubtitleAIConfig(cfg *config.Config) (subtitleai.Config, string) {
transcribeEnabled := cfg.SubtitleAI.TranscribeEnabled
effectiveASRBase := cfg.AI.ASRBaseURL
if effectiveASRBase == "" {
effectiveASRBase = cfg.AI.BaseURL
}
disabledGateway := ""
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
transcribeEnabled = false
disabledGateway = effectiveASRBase
}
return subtitleai.Config{
Configured: cfg.AI.BaseURL != "",
TranslateEnabled: cfg.SubtitleAI.Enabled,
TranscribeEnabled: transcribeEnabled,
ChatModel: cfg.AI.ChatModel,
ASRModel: cfg.AI.ASRModel,
BatchSize: cfg.SubtitleAI.BatchSize,
ContextNeighbors: cfg.SubtitleAI.ContextNeighbors,
TranscribeQuotaJobs: cfg.SubtitleAI.TranscribeQuotaJobs,
TranscribeQuotaPeriod: cfg.SubtitleAI.TranscribeQuotaPeriod,
}, disabledGateway
}
func warnChatOnlyGateway(endpoint string) {
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", endpoint)
}
// metadataAIConfigFromServer derives the metadata translation service config
// from the server config. Used at construction and on every config reload.
func metadataAIConfigFromServer(cfg *config.Config) metadatatranslation.Config {
return metadatatranslation.Config{
Enabled: cfg.MetadataAI.Enabled,
Configured: cfg.AI.BaseURL != "",
ChatModel: cfg.AI.ChatModel,
OnView: cfg.MetadataAI.OnView,
}
}
+68
View File
@@ -2,18 +2,30 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// DefaultGroupSlugsSettingKey is the server setting holding the JSON array of
// group slugs assigned to newly provisioned accounts.
const DefaultGroupSlugsSettingKey = "users.default_group_slugs"
type AccountUserRepository interface {
Create(ctx context.Context, input models.CreateUserInput) (*models.User, error)
Delete(ctx context.Context, id int) error
}
// GroupResolver resolves group slugs to groups. Implemented by GroupRepository.
type GroupResolver interface {
GetBySlug(ctx context.Context, slug string) (*models.Group, error)
}
type DefaultProfileOptions struct {
Enabled bool
Name string
@@ -27,15 +39,21 @@ type CreateAccountInput struct {
type AccountProvisioner struct {
users AccountUserRepository
storeProvider userstore.UserStoreProvider
settings SettingsGetter
groups GroupResolver
}
func NewAccountProvisioner(
users AccountUserRepository,
storeProvider userstore.UserStoreProvider,
settings SettingsGetter,
groups GroupResolver,
) *AccountProvisioner {
return &AccountProvisioner{
users: users,
storeProvider: storeProvider,
settings: settings,
groups: groups,
}
}
@@ -43,6 +61,14 @@ func (p *AccountProvisioner) CreateAccount(
ctx context.Context,
input CreateAccountInput,
) (*models.User, error) {
if input.User.GroupIDs == nil && p.groups != nil {
ids, err := p.defaultGroupIDs(ctx)
if err != nil {
return nil, err
}
input.User.GroupIDs = ids
}
user, err := p.users.Create(ctx, input.User)
if err != nil {
return nil, err
@@ -66,6 +92,48 @@ func (p *AccountProvisioner) CreateAccount(
return user, nil
}
// defaultGroupIDs resolves the configured default group slugs (falling back to
// the built-in users group) to group IDs. Unknown slugs are skipped so a stale
// setting never blocks signups; if no configured slug resolves, the built-in
// users group is used so accounts never start with an empty policy.
func (p *AccountProvisioner) defaultGroupIDs(ctx context.Context) ([]int, error) {
slugs := []string{models.GroupSlugUsers}
if p.settings != nil {
raw, err := p.settings.Get(ctx, DefaultGroupSlugsSettingKey)
if err != nil {
slog.Warn("reading default group slugs setting failed", "key", DefaultGroupSlugsSettingKey, "error", err)
} else if strings.TrimSpace(raw) != "" {
var configured []string
if jsonErr := json.Unmarshal([]byte(raw), &configured); jsonErr != nil {
slog.Warn("parsing default group slugs setting failed", "key", DefaultGroupSlugsSettingKey, "error", jsonErr)
} else if len(configured) > 0 {
slugs = configured
}
}
}
ids := make([]int, 0, len(slugs))
for _, slug := range slugs {
group, err := p.groups.GetBySlug(ctx, slug)
if err != nil {
if errors.Is(err, ErrGroupNotFound) {
// A stale setting must not block signups.
slog.Warn("skipping unknown default group slug", "slug", slug)
continue
}
return nil, fmt.Errorf("resolving default group %q: %w", slug, err)
}
ids = append(ids, group.ID)
}
if len(ids) == 0 {
group, err := p.groups.GetBySlug(ctx, models.GroupSlugUsers)
if err != nil {
return nil, fmt.Errorf("resolving fallback group %q: %w", models.GroupSlugUsers, err)
}
ids = append(ids, group.ID)
}
return ids, nil
}
func (p *AccountProvisioner) createDefaultProfile(
ctx context.Context,
userID int,
+181
View File
@@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
@@ -58,6 +59,8 @@ func TestAccountProvisionerCreateAccount_SkipsProfileByDefault(t *testing.T) {
},
},
nil,
nil,
nil,
)
user, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -90,6 +93,8 @@ func TestAccountProvisionerCreateAccount_CreatesDefaultProfile(t *testing.T) {
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -125,6 +130,8 @@ func TestAccountProvisionerCreateAccount_UsesExplicitProfileName(t *testing.T) {
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -158,6 +165,8 @@ func TestAccountProvisionerCreateAccount_BlankExplicitProfileNameFallsBackToUser
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -194,6 +203,8 @@ func TestAccountProvisionerCreateAccount_DeletesUserWhenProfileCreationFails(t *
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -209,3 +220,173 @@ func TestAccountProvisionerCreateAccount_DeletesUserWhenProfileCreationFails(t *
t.Fatalf("deletedUserID = %d, want 9", deletedUserID)
}
}
type stubGroupResolver struct {
groups map[string]*models.Group
}
func (s stubGroupResolver) GetBySlug(_ context.Context, slug string) (*models.Group, error) {
if group, ok := s.groups[slug]; ok {
return group, nil
}
return nil, ErrGroupNotFound
}
type stubSettings map[string]string
func (s stubSettings) Get(_ context.Context, key string) (string, error) {
return s[key], nil
}
func TestAccountProvisionerCreateAccount_NilGroupIDsResolvesConfiguredDefaults(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["family","kids"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
"kids": {ID: 12, Slug: "kids"},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
want := []int{11, 12}
if !reflect.DeepEqual(gotInput.GroupIDs, want) {
t.Fatalf("GroupIDs = %#v, want %#v", gotInput.GroupIDs, want)
}
}
func TestAccountProvisionerCreateAccount_FallsBackToBuiltInUsersGroup(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
nil, // no settings configured
stubGroupResolver{groups: map[string]*models.Group{
models.GroupSlugUsers: {ID: 2, Slug: models.GroupSlugUsers},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{2}) {
t.Fatalf("GroupIDs = %#v, want [2]", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_ExplicitGroupIDsPassThrough(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["family"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
}},
)
explicit := []int{99}
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex", GroupIDs: explicit},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, explicit) {
t.Fatalf("GroupIDs = %#v, want %#v", gotInput.GroupIDs, explicit)
}
}
func TestAccountProvisionerCreateAccount_SkipsUnknownDefaultSlugs(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost","family"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{11}) {
t.Fatalf("GroupIDs = %#v, want [11]", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_AllUnknownSlugsFallBackToUsersGroup(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost","phantom"]`},
stubGroupResolver{groups: map[string]*models.Group{
models.GroupSlugUsers: {ID: 2, Slug: models.GroupSlugUsers},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{2}) {
t.Fatalf("GroupIDs = %#v, want [2] (built-in users fallback)", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_FallbackGroupMissingReturnsError(t *testing.T) {
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(context.Context, models.CreateUserInput) (*models.User, error) {
t.Fatal("Create should not be called when no default group resolves")
return nil, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost"]`},
stubGroupResolver{groups: map[string]*models.Group{}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err == nil {
t.Fatal("CreateAccount returned nil error, want fallback resolution failure")
}
}
-1
View File
@@ -389,7 +389,6 @@ func (s *DeviceLoginService) Poll(ctx context.Context, deviceCode string) (*Devi
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
+113
View File
@@ -0,0 +1,113 @@
package auth
import (
"sort"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
)
// ApplyEffectivePolicy populates u's policy fields with the most-permissive
// union of the given groups. Groups are the only source of policy:
//
// - permissions: set union; "admin" implies everything
// - libraries: union; any nil (= all) or admin makes it unrestricted
// - limits: 0 means unlimited at enforcement, so any group with 0
// wins; otherwise max across groups
// - quality: most permissive; "" (unrestricted) wins
// - booleans: OR
//
// Zero groups yields the empty policy: no permissions, an empty non-nil
// library list, downloads denied. Lockout is enforced by the empty library
// list (access to no content), which makes the stream/transcode limits
// irrelevant; they stay at 0. MaxProfiles is the exception: 0 means
// unlimited at the profile-creation check, which is independent of library
// access, so it is floored at 1 to keep zero-group accounts from creating
// profiles freely. The quality ceiling is moot with no accessible content
// and is left at the lowest concrete value.
func ApplyEffectivePolicy(u *models.User, groups []models.Group) {
u.GroupIDs = make([]int, 0, len(groups))
u.IsAdmin = false
u.Permissions = []string{}
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles = 0, 0, 0
u.DownloadAllowed, u.DownloadTranscodeAllowed = false, false
u.MaxPlaybackQuality = "480p"
u.LibraryIDs = []int{}
if len(groups) == 0 {
u.MaxProfiles = 1
return
}
permSet := make(map[string]struct{})
libSet := make(map[int]struct{})
allLibraries := false
for i, g := range groups {
u.GroupIDs = append(u.GroupIDs, g.ID)
for _, p := range g.Permissions {
permSet[p] = struct{}{}
}
if g.LibraryIDs == nil {
allLibraries = true
} else {
for _, id := range g.LibraryIDs {
libSet[id] = struct{}{}
}
}
if i == 0 {
u.MaxStreams = g.MaxStreams
u.MaxTranscodes = g.MaxTranscodes
u.MaxProfiles = g.MaxProfiles
u.MaxPlaybackQuality = g.MaxPlaybackQuality
} else {
u.MaxStreams = mergeLimit(u.MaxStreams, g.MaxStreams)
u.MaxTranscodes = mergeLimit(u.MaxTranscodes, g.MaxTranscodes)
u.MaxProfiles = mergeLimit(u.MaxProfiles, g.MaxProfiles)
u.MaxPlaybackQuality = access.MaxQuality(u.MaxPlaybackQuality, g.MaxPlaybackQuality)
}
u.DownloadAllowed = u.DownloadAllowed || g.DownloadAllowed
u.DownloadTranscodeAllowed = u.DownloadTranscodeAllowed || g.DownloadTranscodeAllowed
}
// Admin implies unrestricted libraries, quality, and downloads. Stream,
// transcode, and profile limits intentionally keep the merged group
// values: before groups, admins were subject to their per-user limits
// (default 6 streams) with no bypass, and the playback session manager
// still enforces whatever it is handed.
if _, ok := permSet[string(PermissionAdmin)]; ok {
u.IsAdmin = true
allLibraries = true
u.MaxPlaybackQuality = ""
u.DownloadAllowed = true
}
u.Permissions = make([]string, 0, len(permSet))
for p := range permSet {
u.Permissions = append(u.Permissions, p)
}
sort.Strings(u.Permissions)
if allLibraries {
u.LibraryIDs = nil
} else {
u.LibraryIDs = make([]int, 0, len(libSet))
for id := range libSet {
u.LibraryIDs = append(u.LibraryIDs, id)
}
sort.Ints(u.LibraryIDs)
}
}
// mergeLimit folds group limits where 0 means unlimited at enforcement
// (max_streams, max_transcodes, max_profiles), so any unlimited group wins
// the most-permissive union; otherwise the higher cap wins.
func mergeLimit(current, next int) int {
if current == 0 || next == 0 {
return 0
}
if next > current {
return next
}
return current
}
+158
View File
@@ -0,0 +1,158 @@
package auth
import (
"reflect"
"sort"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestApplyEffectivePolicyZeroGroups(t *testing.T) {
u := &models.User{ID: 1, Enabled: true}
ApplyEffectivePolicy(u, nil)
if u.IsAdmin {
t.Error("zero groups must not grant admin")
}
if len(u.Permissions) != 0 {
t.Errorf("permissions = %v, want empty", u.Permissions)
}
if u.LibraryIDs == nil || len(u.LibraryIDs) != 0 {
t.Errorf("library ids = %v, want empty non-nil (access to none)", u.LibraryIDs)
}
// Lockout comes from the empty library list; stream/transcode limits are
// irrelevant and stay 0. MaxProfiles is floored at 1 because 0 means
// unlimited at the profile-creation check.
if u.MaxStreams != 0 || u.MaxTranscodes != 0 {
t.Errorf("stream limits = %d/%d, want 0/0", u.MaxStreams, u.MaxTranscodes)
}
if u.MaxProfiles != 1 {
t.Errorf("max profiles = %d, want 1 (0 would mean unlimited)", u.MaxProfiles)
}
if u.DownloadAllowed || u.DownloadTranscodeAllowed {
t.Error("downloads must be denied for zero groups")
}
}
func TestApplyEffectivePolicyUnion(t *testing.T) {
u := &models.User{ID: 1, Enabled: true}
ApplyEffectivePolicy(u, []models.Group{
{ID: 10, Permissions: []string{"marker_edit"}, LibraryIDs: []int{1, 2},
MaxStreams: 2, MaxTranscodes: 1, MaxProfiles: 3,
MaxPlaybackQuality: "1080p", DownloadAllowed: false},
{ID: 20, Permissions: []string{"metadata_curation"}, LibraryIDs: []int{2, 3},
MaxStreams: 4, MaxTranscodes: 2, MaxProfiles: 1,
MaxPlaybackQuality: "2160p", DownloadAllowed: true},
})
wantPerms := []string{"marker_edit", "metadata_curation"}
sort.Strings(u.Permissions)
if !reflect.DeepEqual(u.Permissions, wantPerms) {
t.Errorf("permissions = %v, want %v", u.Permissions, wantPerms)
}
wantLibs := []int{1, 2, 3}
sort.Ints(u.LibraryIDs)
if !reflect.DeepEqual(u.LibraryIDs, wantLibs) {
t.Errorf("libraries = %v, want %v", u.LibraryIDs, wantLibs)
}
if u.MaxStreams != 4 || u.MaxTranscodes != 2 || u.MaxProfiles != 3 {
t.Errorf("limits = %d/%d/%d, want 4/2/3", u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
if u.MaxPlaybackQuality != "2160p" {
t.Errorf("quality = %q, want 2160p", u.MaxPlaybackQuality)
}
if !u.DownloadAllowed {
t.Error("downloads must be OR-ed")
}
wantGroups := []int{10, 20}
sort.Ints(u.GroupIDs)
if !reflect.DeepEqual(u.GroupIDs, wantGroups) {
t.Errorf("group ids = %v, want %v", u.GroupIDs, wantGroups)
}
if u.IsAdmin {
t.Error("no group granted admin")
}
}
func TestApplyEffectivePolicyUnlimitedLimitWins(t *testing.T) {
// 0 means unlimited at enforcement for all three limits, so a group with
// 0 must win the most-permissive union over any finite cap.
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxStreams: 5, MaxTranscodes: 5, MaxProfiles: 5},
{ID: 2, MaxStreams: 0, MaxTranscodes: 0, MaxProfiles: 0},
})
if u.MaxStreams != 0 || u.MaxTranscodes != 0 || u.MaxProfiles != 0 {
t.Errorf("limits = %d/%d/%d, want 0/0/0 (unlimited wins)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
// Order must not matter: unlimited in the first group sticks.
u = &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 2, MaxStreams: 0, MaxTranscodes: 0, MaxProfiles: 0},
{ID: 1, MaxStreams: 5, MaxTranscodes: 5, MaxProfiles: 5},
})
if u.MaxStreams != 0 || u.MaxTranscodes != 0 || u.MaxProfiles != 0 {
t.Errorf("limits = %d/%d/%d, want 0/0/0 (unlimited wins regardless of order)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
}
func TestApplyEffectivePolicyFiniteLimitsTakeMax(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxStreams: 5, MaxTranscodes: 2, MaxProfiles: 4},
{ID: 2, MaxStreams: 2, MaxTranscodes: 5, MaxProfiles: 1},
})
if u.MaxStreams != 5 || u.MaxTranscodes != 5 || u.MaxProfiles != 4 {
t.Errorf("limits = %d/%d/%d, want 5/5/4 (max of finite caps)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
}
func TestApplyEffectivePolicyNilLibrariesMeansAll(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, LibraryIDs: []int{1}},
{ID: 2, LibraryIDs: nil}, // all libraries
})
if u.LibraryIDs != nil {
t.Errorf("library ids = %v, want nil (unrestricted)", u.LibraryIDs)
}
}
func TestApplyEffectivePolicyAdminShortCircuit(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, Permissions: []string{"admin"}, LibraryIDs: []int{1},
MaxStreams: 1, MaxPlaybackQuality: "480p"},
})
if !u.IsAdmin {
t.Fatal("admin permission must set IsAdmin")
}
if u.LibraryIDs != nil {
t.Error("admin must be library-unrestricted")
}
if u.MaxPlaybackQuality != "" {
t.Error("admin must be quality-unrestricted")
}
// Admin does not bypass stream/transcode/profile limits (matching the
// pre-group behavior where admins were subject to per-user limits); the
// merged group values stand.
if u.MaxStreams != 1 {
t.Errorf("max streams = %d, want 1 (admin keeps merged group limits)", u.MaxStreams)
}
}
func TestApplyEffectivePolicyQualityUnrestrictedWins(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxPlaybackQuality: "1080p"},
{ID: 2, MaxPlaybackQuality: ""}, // unrestricted
})
if u.MaxPlaybackQuality != "" {
t.Errorf("quality = %q, want unrestricted", u.MaxPlaybackQuality)
}
}
+604
View File
@@ -0,0 +1,604 @@
package auth
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/text/unicode/norm"
"github.com/Silo-Server/silo-server/internal/models"
)
// Sentinel errors for group operations.
var (
ErrGroupNotFound = errors.New("group not found")
ErrBuiltInGroup = errors.New("built-in group cannot be deleted")
ErrAdminPermRequired = errors.New("administrators group must keep the admin permission")
ErrLastAdministrator = errors.New("cannot remove the last enabled administrator")
ErrUnknownGroup = errors.New("unknown group")
ErrUnknownUser = errors.New("unknown user")
)
// GroupRepository provides CRUD and membership operations for groups.
// All mutations bump access_policy_revision for affected users set-based so
// the access resolver invalidates stale scopes.
type GroupRepository struct {
pool *pgxpool.Pool
}
func NewGroupRepository(pool *pgxpool.Pool) *GroupRepository {
return &GroupRepository{pool: pool}
}
// groupColumns is table-qualified so it stays unambiguous in queries that
// join user_groups (which also has a created_at column). Qualified names are
// valid in plain SELECTs and in INSERT/UPDATE ... RETURNING alike.
const groupColumns = `groups.id, groups.slug, groups.name, groups.description,
groups.built_in, groups.permissions, groups.library_ids, groups.max_streams,
groups.max_transcodes, groups.max_profiles, groups.max_playback_quality,
groups.download_allowed, groups.download_transcode_allowed,
groups.created_at, groups.updated_at`
func scanGroupFields(row pgx.Row, g *models.Group, extra ...any) error {
dest := []any{
&g.ID, &g.Slug, &g.Name, &g.Description, &g.BuiltIn, &g.Permissions,
&g.LibraryIDs, &g.MaxStreams, &g.MaxTranscodes, &g.MaxProfiles,
&g.MaxPlaybackQuality, &g.DownloadAllowed, &g.DownloadTranscodeAllowed,
&g.CreatedAt, &g.UpdatedAt,
}
dest = append(dest, extra...)
return row.Scan(dest...)
}
func scanGroup(row pgx.Row) (*models.Group, error) {
var g models.Group
if err := scanGroupFields(row, &g); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrGroupNotFound
}
return nil, fmt.Errorf("scanning group: %w", err)
}
return &g, nil
}
var slugStripRe = regexp.MustCompile(`[^a-z0-9]+`)
// slugifyGroupName derives a stable slug from a display name.
// Accented characters are decomposed via NFD and their combining marks are
// stripped so that e.g. "ï" → "i" before the ASCII-only filter is applied.
func slugifyGroupName(name string) string {
// NFD-decompose so accented letters split into base + combining mark.
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(name)))
// Drop combining (diacritic) runes; keep everything else for the regex.
var b strings.Builder
for _, r := range decomposed {
if unicode.Is(unicode.Mn, r) {
continue
}
b.WriteRune(r)
}
s := slugStripRe.ReplaceAllString(b.String(), "-")
return strings.Trim(s, "-")
}
// GroupWithMemberCount pairs a group with its member count for list views.
type GroupWithMemberCount struct {
models.Group
MemberCount int
}
// List returns all groups with member counts, built-ins first.
func (r *GroupRepository) List(ctx context.Context) ([]GroupWithMemberCount, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`,
(SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = groups.id) AS member_count
FROM groups
ORDER BY built_in DESC, name ASC`)
if err != nil {
return nil, fmt.Errorf("listing groups: %w", err)
}
defer rows.Close()
var out []GroupWithMemberCount
for rows.Next() {
var g GroupWithMemberCount
if err := scanGroupFields(rows, &g.Group, &g.MemberCount); err != nil {
return nil, fmt.Errorf("scanning group row: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
func (r *GroupRepository) GetByID(ctx context.Context, id int) (*models.Group, error) {
return scanGroup(r.pool.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1`, id))
}
func (r *GroupRepository) GetBySlug(ctx context.Context, slug string) (*models.Group, error) {
return scanGroup(r.pool.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE slug = $1`, slug))
}
// GroupsForUser returns the groups a user belongs to.
func (r *GroupRepository) GroupsForUser(ctx context.Context, userID int) ([]models.Group, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`
FROM groups
JOIN user_groups ug ON ug.group_id = groups.id
WHERE ug.user_id = $1
ORDER BY groups.id`, userID)
if err != nil {
return nil, fmt.Errorf("loading user groups: %w", err)
}
defer rows.Close()
var out []models.Group
for rows.Next() {
var g models.Group
if err := scanGroupFields(rows, &g); err != nil {
return nil, fmt.Errorf("scanning group row: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// GroupsForUsers returns group memberships for many users in one query.
func (r *GroupRepository) GroupsForUsers(ctx context.Context, userIDs []int) (map[int][]models.Group, error) {
out := make(map[int][]models.Group, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`, ug.user_id
FROM groups
JOIN user_groups ug ON ug.group_id = groups.id
WHERE ug.user_id = ANY($1::int[])
ORDER BY groups.id`, userIDs)
if err != nil {
return nil, fmt.Errorf("loading users' groups: %w", err)
}
defer rows.Close()
for rows.Next() {
var userID int
var g models.Group
if err := scanGroupFields(rows, &g, &userID); err != nil {
return nil, fmt.Errorf("scanning user group row: %w", err)
}
out[userID] = append(out[userID], g)
}
return out, rows.Err()
}
// Create inserts a new (non-built-in) group.
func (r *GroupRepository) Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error) {
permissions, err := NormalizePermissions(input.Permissions)
if err != nil {
return nil, err
}
slug := slugifyGroupName(input.Name)
if slug == "" {
return nil, fmt.Errorf("group name must contain at least one alphanumeric character")
}
cols := []string{"slug", "name", "description", "permissions", "library_ids"}
args := []any{slug, strings.TrimSpace(input.Name), input.Description, permissions, input.LibraryIDs}
type optionalCol struct {
col string
val any
set bool
}
optional := []optionalCol{
{"max_streams", derefAny(input.MaxStreams), input.MaxStreams != nil},
{"max_transcodes", derefAny(input.MaxTranscodes), input.MaxTranscodes != nil},
{"max_profiles", derefAny(input.MaxProfiles), input.MaxProfiles != nil},
{"max_playback_quality", derefAny(input.MaxPlaybackQuality), input.MaxPlaybackQuality != nil},
{"download_allowed", derefAny(input.DownloadAllowed), input.DownloadAllowed != nil},
{"download_transcode_allowed", derefAny(input.DownloadTranscodeAllowed), input.DownloadTranscodeAllowed != nil},
}
for _, o := range optional {
if o.set {
cols = append(cols, o.col)
args = append(args, o.val)
}
}
placeholders := make([]string, len(args))
for i := range args {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf("INSERT INTO groups (%s) VALUES (%s) RETURNING %s",
strings.Join(cols, ", "), strings.Join(placeholders, ", "), groupColumns)
group, err := scanGroup(r.pool.QueryRow(ctx, query, args...))
if err != nil {
if isDuplicateKeyError(err) {
return nil, fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
}
return nil, fmt.Errorf("creating group: %w", err)
}
return group, nil
}
func derefAny[T any](p *T) any {
if p == nil {
return nil
}
return *p
}
// Update modifies a group and bumps all members' access_policy_revision when
// a policy field changes. The administrators group cannot lose the admin
// permission.
func (r *GroupRepository) Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("beginning group update: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Lock the group row: serializes concurrent policy/membership mutations.
current, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, id))
if err != nil {
return nil, err
}
var normalizedPerms []string
if input.Permissions != nil {
var err error
normalizedPerms, err = NormalizePermissions(*input.Permissions)
if err != nil {
return nil, err
}
if current.Slug == models.GroupSlugAdministrators {
hasAdmin := false
for _, p := range normalizedPerms {
if p == string(PermissionAdmin) {
hasAdmin = true
break
}
}
if !hasAdmin {
return nil, ErrAdminPermRequired
}
}
}
setClauses := []string{}
policyChanged := false
args := []any{}
idx := 1
add := func(col string, val any, policy bool) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
if policy {
policyChanged = true
}
}
if input.Name != nil {
add("name", strings.TrimSpace(*input.Name), false)
}
if input.Description != nil {
add("description", *input.Description, false)
}
if input.Permissions != nil {
add("permissions", normalizedPerms, true)
}
if input.LibraryIDs != nil {
add("library_ids", *input.LibraryIDs, true)
}
if input.MaxStreams != nil {
add("max_streams", *input.MaxStreams, true)
}
if input.MaxTranscodes != nil {
add("max_transcodes", *input.MaxTranscodes, true)
}
if input.MaxProfiles != nil {
add("max_profiles", *input.MaxProfiles, true)
}
if input.MaxPlaybackQuality != nil {
add("max_playback_quality", *input.MaxPlaybackQuality, true)
}
if input.DownloadAllowed != nil {
add("download_allowed", *input.DownloadAllowed, true)
}
if input.DownloadTranscodeAllowed != nil {
add("download_transcode_allowed", *input.DownloadTranscodeAllowed, true)
}
if len(setClauses) == 0 {
return current, nil
}
setClauses = append(setClauses, "updated_at = NOW()")
args = append(args, id)
query := fmt.Sprintf("UPDATE groups SET %s WHERE id = $%d RETURNING %s",
strings.Join(setClauses, ", "), idx, groupColumns)
updated, err := scanGroup(tx.QueryRow(ctx, query, args...))
if err != nil {
return nil, fmt.Errorf("updating group: %w", err)
}
if policyChanged {
if err := bumpGroupMemberRevisions(ctx, tx, id); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("committing group update: %w", err)
}
return updated, nil
}
// bumpGroupMemberRevisions invalidates all members' resolved scopes in one
// set-based statement (never loop per user — groups can have thousands of
// members).
func bumpGroupMemberRevisions(ctx context.Context, tx pgx.Tx, groupID int) error {
_, err := tx.Exec(ctx, `
UPDATE users u
SET access_policy_revision = access_policy_revision + 1
FROM user_groups ug
WHERE ug.user_id = u.id AND ug.group_id = $1`, groupID)
if err != nil {
return fmt.Errorf("bumping member policy revisions: %w", err)
}
return nil
}
// Delete removes a non-built-in group. Member revisions are bumped before the
// memberships cascade away.
func (r *GroupRepository) Delete(ctx context.Context, id int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning group delete: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
group, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, id))
if err != nil {
return err
}
if group.BuiltIn {
return ErrBuiltInGroup
}
if err := bumpGroupMemberRevisions(ctx, tx, id); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM groups WHERE id = $1`, id); err != nil {
return fmt.Errorf("deleting group: %w", err)
}
return tx.Commit(ctx)
}
// MemberCount returns the number of users in a group.
func (r *GroupRepository) MemberCount(ctx context.Context, groupID int) (int, error) {
var count int
if err := r.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM user_groups WHERE group_id = $1`, groupID).Scan(&count); err != nil {
return 0, fmt.Errorf("counting group members: %w", err)
}
return count, nil
}
// GroupMember is one row of a paginated member listing.
type GroupMember struct {
UserID int
Username string
Email string
Enabled bool
}
// ListMembers returns one page of a group's members plus the total count.
func (r *GroupRepository) ListMembers(ctx context.Context, groupID, offset, limit int) ([]GroupMember, int, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
total, err := r.MemberCount(ctx, groupID)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT u.id, u.username, u.email, u.enabled
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1
ORDER BY u.username
OFFSET $2 LIMIT $3`, groupID, offset, limit)
if err != nil {
return nil, 0, fmt.Errorf("listing members: %w", err)
}
defer rows.Close()
var members []GroupMember
for rows.Next() {
var m GroupMember
if err := rows.Scan(&m.UserID, &m.Username, &m.Email, &m.Enabled); err != nil {
return nil, 0, fmt.Errorf("scanning member: %w", err)
}
members = append(members, m)
}
return members, total, rows.Err()
}
// AddMember adds a user to a group (idempotent) and bumps their revision.
func (r *GroupRepository) AddMember(ctx context.Context, groupID, userID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning add member: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, groupID)); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, userID, groupID)
if err != nil {
if isForeignKeyError(err) {
return ErrUnknownUser
}
return fmt.Errorf("adding member: %w", err)
}
if tag.RowsAffected() > 0 {
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping member revision: %w", err)
}
}
return tx.Commit(ctx)
}
// lastEnabledAdministratorGuard returns ErrLastAdministrator when userID is an
// enabled member of the administrators group and no other enabled member
// exists, i.e. when removing, disabling, or deleting userID would leave the
// server without an enabled administrator. It locks the administrators group
// row (FOR UPDATE) so concurrent membership/disable/delete mutations
// serialize; re-locking the same row later in the same transaction is a no-op.
func lastEnabledAdministratorGuard(ctx context.Context, tx pgx.Tx, excludingUserID int) error {
var adminGroupID int
if err := tx.QueryRow(ctx, `
SELECT id FROM groups WHERE slug = $1 FOR UPDATE`,
models.GroupSlugAdministrators).Scan(&adminGroupID); err != nil {
return fmt.Errorf("locking administrators group: %w", err)
}
// Only an enabled administrator can be the last enabled administrator;
// mutating a non-member or disabled member never breaks the invariant.
var enabledMember bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1 AND ug.user_id = $2 AND u.enabled)`,
adminGroupID, excludingUserID).Scan(&enabledMember); err != nil {
return fmt.Errorf("checking administrators membership: %w", err)
}
if !enabledMember {
return nil
}
var remaining int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1 AND u.enabled AND ug.user_id <> $2`,
adminGroupID, excludingUserID).Scan(&remaining); err != nil {
return fmt.Errorf("counting administrators: %w", err)
}
if remaining == 0 {
return ErrLastAdministrator
}
return nil
}
// RemoveMember removes a user from a group. Removing the last enabled member
// of the administrators group is rejected.
func (r *GroupRepository) RemoveMember(ctx context.Context, groupID, userID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning remove member: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
group, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, groupID))
if err != nil {
return err
}
if group.Slug == models.GroupSlugAdministrators {
if err := lastEnabledAdministratorGuard(ctx, tx, userID); err != nil {
return err
}
}
tag, err := tx.Exec(ctx,
`DELETE FROM user_groups WHERE group_id = $1 AND user_id = $2`, groupID, userID)
if err != nil {
return fmt.Errorf("removing member: %w", err)
}
if tag.RowsAffected() > 0 {
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping member revision: %w", err)
}
}
return tx.Commit(ctx)
}
// ReplaceUserGroups sets a user's memberships to exactly groupIDs, applying
// the last-administrator guard if administrators membership is being removed.
func (r *GroupRepository) ReplaceUserGroups(ctx context.Context, userID int, groupIDs []int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning group replacement: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := r.replaceUserGroupsTx(ctx, tx, userID, groupIDs); err != nil {
return err
}
return tx.Commit(ctx)
}
// replaceUserGroupsTx performs the membership replacement inside the caller's
// transaction so it can be composed with other writes atomically.
func (r *GroupRepository) replaceUserGroupsTx(ctx context.Context, tx pgx.Tx, userID int, groupIDs []int) error {
// Lock the administrators group row so concurrent removals serialize.
var adminGroupID int
if err := tx.QueryRow(ctx, `
SELECT id FROM groups WHERE slug = $1 FOR UPDATE`,
models.GroupSlugAdministrators).Scan(&adminGroupID); err != nil {
return fmt.Errorf("locking administrators group: %w", err)
}
keepingAdmin := false
for _, id := range groupIDs {
if id == adminGroupID {
keepingAdmin = true
break
}
}
if !keepingAdmin {
if err := lastEnabledAdministratorGuard(ctx, tx, userID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `DELETE FROM user_groups WHERE user_id = $1`, userID); err != nil {
return fmt.Errorf("clearing memberships: %w", err)
}
if len(groupIDs) > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO user_groups (user_id, group_id)
SELECT $1, unnest($2::int[])
ON CONFLICT DO NOTHING`, userID, groupIDs); err != nil {
if isForeignKeyError(err) {
return fmt.Errorf("%w", ErrUnknownGroup)
}
return fmt.Errorf("adding memberships: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping user revision: %w", err)
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package auth
import "testing"
func TestSlugifyGroupName(t *testing.T) {
cases := []struct{ in, want string }{
{"Family", "family"},
{"Power Users", "power-users"},
{" Trimmed ", "trimmed"},
{"Ünïcode & Symbols!", "unicode-symbols"},
{"--multi---dash--", "multi-dash"},
}
for _, tc := range cases {
if got := slugifyGroupName(tc.in); got != tc.want {
t.Errorf("slugifyGroupName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
+14 -27
View File
@@ -3,7 +3,6 @@ package auth
import (
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -18,7 +17,6 @@ var (
// Claims represents the custom JWT claims used for authentication.
type Claims struct {
UserID int `json:"user_id"`
Role string `json:"role"`
SessionID string `json:"session_id"`
TokenType string `json:"token_type"`
ImpersonatorUserID *int `json:"impersonator_user_id,omitempty"`
@@ -37,76 +35,65 @@ const (
const PluginAccessCookieName = "silo_plugin_access"
// JWTService handles JWT token generation and validation using HMAC-SHA256.
// The expiry durations are atomics so they can be hot-reloaded from admin
// settings while tokens are being generated; the secret is fixed for the
// service lifetime (changing it would invalidate every outstanding token).
type JWTService struct {
secret []byte
accessExpiry atomic.Int64 // nanoseconds
refreshExpiry atomic.Int64 // nanoseconds
accessExpiry time.Duration
refreshExpiry time.Duration
}
// NewJWTService creates a new JWTService with the given secret and expiry durations.
func NewJWTService(secret string, accessExpiry, refreshExpiry time.Duration) *JWTService {
j := &JWTService{secret: []byte(secret)}
j.SetExpiries(accessExpiry, refreshExpiry)
return j
}
// SetExpiries updates the token expiry durations. Safe for concurrent use;
// applies to tokens generated afterwards.
func (j *JWTService) SetExpiries(access, refresh time.Duration) {
j.accessExpiry.Store(int64(access))
j.refreshExpiry.Store(int64(refresh))
return &JWTService{
secret: []byte(secret),
accessExpiry: accessExpiry,
refreshExpiry: refreshExpiry,
}
}
// AccessExpiry returns the configured access token expiry duration.
func (j *JWTService) AccessExpiry() time.Duration {
return time.Duration(j.accessExpiry.Load())
return j.accessExpiry
}
// RefreshExpiry returns the configured refresh token expiry duration.
func (j *JWTService) RefreshExpiry() time.Duration {
return time.Duration(j.refreshExpiry.Load())
return j.refreshExpiry
}
// GenerateAccessToken creates a signed JWT access token with the configured
// access token expiry duration.
func (j *JWTService) GenerateAccessToken(userID int, role, sessionID string) (string, error) {
func (j *JWTService) GenerateAccessToken(userID int, sessionID string) (string, error) {
return j.generateAccessToken(Claims{
UserID: userID,
Role: role,
SessionID: sessionID,
})
}
// GenerateRefreshToken creates a signed JWT refresh token with the configured
// refresh token expiry duration.
func (j *JWTService) GenerateRefreshToken(userID int, role, sessionID string) (string, error) {
func (j *JWTService) GenerateRefreshToken(userID int, sessionID string) (string, error) {
return j.generateRefreshToken(Claims{
UserID: userID,
Role: role,
SessionID: sessionID,
})
}
func (j *JWTService) GeneratePluginAccessToken(userID int, role, sessionID string, ttl time.Duration) (string, error) {
func (j *JWTService) GeneratePluginAccessToken(userID int, sessionID string, ttl time.Duration) (string, error) {
if ttl <= 0 {
ttl = 5 * time.Minute
}
return j.generateToken(Claims{
UserID: userID,
Role: role,
SessionID: sessionID,
}, TokenTypePluginAccess, ttl)
}
func (j *JWTService) generateAccessToken(claims Claims) (string, error) {
return j.generateToken(claims, TokenTypeAccess, j.AccessExpiry())
return j.generateToken(claims, TokenTypeAccess, j.accessExpiry)
}
func (j *JWTService) generateRefreshToken(claims Claims) (string, error) {
return j.generateToken(claims, TokenTypeRefresh, j.RefreshExpiry())
return j.generateToken(claims, TokenTypeRefresh, j.refreshExpiry)
}
// generateToken creates a signed JWT with the given claims and expiry duration.
+19 -20
View File
@@ -18,7 +18,7 @@ func newTestJWTService() *auth.JWTService {
func TestJWT_GenerateAccessToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateAccessToken(42, "admin", "sess-abc-123")
token, err := svc.GenerateAccessToken(42, "sess-abc-123")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -36,7 +36,7 @@ func TestJWT_GenerateAccessToken(t *testing.T) {
func TestJWT_GenerateRefreshToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateRefreshToken(42, "user", "sess-def-456")
token, err := svc.GenerateRefreshToken(42, "sess-def-456")
if err != nil {
t.Fatalf("GenerateRefreshToken() error: %v", err)
}
@@ -48,7 +48,7 @@ func TestJWT_GenerateRefreshToken(t *testing.T) {
func TestJWT_ValidateAccessToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateAccessToken(1, "user", "sess-uuid-1")
token, err := svc.GenerateAccessToken(1, "sess-uuid-1")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -61,9 +61,6 @@ func TestJWT_ValidateAccessToken(t *testing.T) {
if claims.UserID != 1 {
t.Errorf("UserID = %d, want 1", claims.UserID)
}
if claims.Role != "user" {
t.Errorf("Role = %q, want %q", claims.Role, "user")
}
if claims.SessionID != "sess-uuid-1" {
t.Errorf("SessionID = %q, want %q", claims.SessionID, "sess-uuid-1")
}
@@ -91,7 +88,7 @@ func TestJWT_ValidateAccessToken(t *testing.T) {
func TestJWT_ValidateRefreshToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateRefreshToken(99, "admin", "sess-uuid-2")
token, err := svc.GenerateRefreshToken(99, "sess-uuid-2")
if err != nil {
t.Fatalf("GenerateRefreshToken() error: %v", err)
}
@@ -104,9 +101,6 @@ func TestJWT_ValidateRefreshToken(t *testing.T) {
if claims.UserID != 99 {
t.Errorf("UserID = %d, want 99", claims.UserID)
}
if claims.Role != "admin" {
t.Errorf("Role = %q, want %q", claims.Role, "admin")
}
if claims.SessionID != "sess-uuid-2" {
t.Errorf("SessionID = %q, want %q", claims.SessionID, "sess-uuid-2")
}
@@ -118,12 +112,12 @@ func TestJWT_ValidateRefreshToken(t *testing.T) {
func TestJWT_AccessTokenExpiry(t *testing.T) {
svc := newTestJWTService()
accessToken, err := svc.GenerateAccessToken(1, "user", "sess-1")
accessToken, err := svc.GenerateAccessToken(1, "sess-1")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
refreshToken, err := svc.GenerateRefreshToken(1, "user", "sess-1")
refreshToken, err := svc.GenerateRefreshToken(1, "sess-1")
if err != nil {
t.Fatalf("GenerateRefreshToken() error: %v", err)
}
@@ -162,7 +156,7 @@ func TestJWT_ExpiredToken(t *testing.T) {
// Create a service with a negative expiry so tokens are immediately expired.
svc := auth.NewJWTService(testSecret, -1*time.Second, -1*time.Second)
token, err := svc.GenerateAccessToken(1, "user", "sess-expired")
token, err := svc.GenerateAccessToken(1, "sess-expired")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -176,13 +170,19 @@ func TestJWT_ExpiredToken(t *testing.T) {
func TestJWT_TamperedToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateAccessToken(1, "user", "sess-tamper")
token, err := svc.GenerateAccessToken(1, "sess-tamper")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
// Tamper with the token by modifying the last character of the signature.
tampered := token[:len(token)-1] + "X"
// Tamper with the token by replacing the last character of the signature
// with a different one (a fixed substitute would be a no-op whenever the
// real signature already ends in that character).
replacement := "X"
if token[len(token)-1] == 'X' {
replacement = "Y"
}
tampered := token[:len(token)-1] + replacement
_, err = svc.ValidateToken(tampered)
if err == nil {
@@ -194,7 +194,7 @@ func TestJWT_WrongSecret(t *testing.T) {
svc1 := auth.NewJWTService("secret-one", 15*time.Minute, 7*24*time.Hour)
svc2 := auth.NewJWTService("secret-two", 15*time.Minute, 7*24*time.Hour)
token, err := svc1.GenerateAccessToken(1, "user", "sess-wrong-secret")
token, err := svc1.GenerateAccessToken(1, "sess-wrong-secret")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -210,7 +210,6 @@ func TestJWT_WrongSigningMethod(t *testing.T) {
// We construct a token with alg=none to ensure the validator rejects it.
claims := jwt.MapClaims{
"user_id": 1,
"role": "admin",
"session_id": "sess-none",
"token_type": auth.TokenTypeAccess,
"exp": time.Now().Add(1 * time.Hour).Unix(),
@@ -250,12 +249,12 @@ func TestJWT_GarbageToken(t *testing.T) {
func TestJWT_DifferentUsersGetDifferentTokens(t *testing.T) {
svc := newTestJWTService()
token1, err := svc.GenerateAccessToken(1, "user", "sess-1")
token1, err := svc.GenerateAccessToken(1, "sess-1")
if err != nil {
t.Fatalf("GenerateAccessToken(1) error: %v", err)
}
token2, err := svc.GenerateAccessToken(2, "admin", "sess-2")
token2, err := svc.GenerateAccessToken(2, "sess-2")
if err != nil {
t.Fatalf("GenerateAccessToken(2) error: %v", err)
}
+4 -6
View File
@@ -11,11 +11,13 @@ import (
type Permission string
const (
PermissionAdmin Permission = "admin"
PermissionMarkerEdit Permission = "marker_edit"
PermissionMetadataCuration Permission = "metadata_curation"
)
var assignablePermissions = map[Permission]struct{}{
PermissionAdmin: {},
PermissionMarkerEdit: {},
PermissionMetadataCuration: {},
}
@@ -60,10 +62,6 @@ func NormalizePermissions(values []string) ([]string, error) {
return out, nil
}
func DefaultUserPermissions() []string {
return []string{string(PermissionMarkerEdit)}
}
func HasAssignedPermission(user *models.User, permission Permission) bool {
if user == nil {
return false
@@ -80,7 +78,7 @@ func HasEffectivePermission(user *models.User, permission Permission) bool {
if user == nil || !user.Enabled {
return false
}
if user.Role == "admin" {
if user.IsAdmin {
return isAssignablePermission(permission)
}
return HasAssignedPermission(user, permission)
@@ -90,7 +88,7 @@ func EffectivePermissions(user *models.User) []string {
if user == nil || !user.Enabled {
return []string{}
}
if user.Role == "admin" {
if user.IsAdmin {
return assignablePermissionList()
}
permissions, err := NormalizePermissions(user.Permissions)
+19 -7
View File
@@ -30,7 +30,7 @@ func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) {
}
func TestHasEffectivePermission_AdminImpliesAssignablePermissions(t *testing.T) {
user := &models.User{Role: "admin", Enabled: true}
user := &models.User{IsAdmin: true, Enabled: true}
if !HasEffectivePermission(user, PermissionMetadataCuration) {
t.Fatal("admin should have metadata curation")
}
@@ -40,7 +40,7 @@ func TestHasEffectivePermission_AdminImpliesAssignablePermissions(t *testing.T)
}
func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
user := &models.User{Role: "user", Enabled: true}
user := &models.User{Enabled: true}
if HasEffectivePermission(user, PermissionMetadataCuration) {
t.Fatal("plain user should not have metadata curation")
}
@@ -50,10 +50,22 @@ func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
}
}
func TestDefaultUserPermissionsIncludesMarkerEditOnly(t *testing.T) {
got := DefaultUserPermissions()
want := []string{"marker_edit"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("default permissions = %#v, want %#v", got, want)
func TestAdminPermissionIsAssignable(t *testing.T) {
normalized, err := NormalizePermissions([]string{"admin"})
if err != nil {
t.Fatalf("admin must be an assignable permission: %v", err)
}
if len(normalized) != 1 || normalized[0] != "admin" {
t.Fatalf("got %v, want [admin]", normalized)
}
}
func TestIsAdminGrantsAllPermissions(t *testing.T) {
u := &models.User{Enabled: true, IsAdmin: true}
if !HasEffectivePermission(u, PermissionMarkerEdit) {
t.Error("admin should hold marker_edit")
}
if !HasEffectivePermission(u, PermissionMetadataCuration) {
t.Error("admin should hold metadata_curation")
}
}
+1 -2
View File
@@ -54,7 +54,7 @@ func NewPluginProviderWithClientFactory(
sessions: sessions,
users: users,
identityPool: pool,
accounts: NewAccountProvisioner(users, nil),
accounts: NewAccountProvisioner(users, nil, nil, groupResolverFor(users)),
}
}
@@ -264,7 +264,6 @@ func (p *PluginProvider) autoProvisionUser(
Username: username,
Password: password,
LocalPasswordLoginEnabled: &localPasswordLoginEnabled,
Role: "user",
},
})
if err == nil {
+187 -178
View File
@@ -30,6 +30,11 @@ func IsDuplicate(err error) bool {
return errors.Is(err, ErrDuplicate)
}
// UserLoader loads a user by ID with effective policy hydrated.
type UserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// CheckPassword verifies a plaintext password against the user's bcrypt hash.
// This is a standalone function, not a repository method.
func CheckPassword(user *models.User, password string) bool {
@@ -37,22 +42,25 @@ func CheckPassword(user *models.User, password string) bool {
return err == nil
}
// UserRepository provides CRUD operations for the users table.
// UserRepository provides CRUD operations for the users table. Effective
// access policy is derived from group memberships when users are loaded.
type UserRepository struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
groups *GroupRepository
}
// NewUserRepository creates a new UserRepository backed by the given pool.
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool}
return &UserRepository{pool: pool, groups: NewGroupRepository(pool)}
}
// Groups exposes the group repository sharing this repository's pool.
func (r *UserRepository) Groups() *GroupRepository { return r.groups }
// allColumns is the list of columns returned by all SELECT queries.
// Kept in one place so scanUser stays in sync.
const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled,
library_ids, max_playback_quality, access_policy_revision,
max_streams, max_transcodes, max_profiles, download_allowed,
download_transcode_allowed, created_at, updated_at`
const allColumns = `id, email, username, password_hash, local_password_login_enabled, enabled,
access_policy_revision, created_at, updated_at`
// scanUser scans a single row into a *models.User.
func scanUser(row pgx.Row) (*models.User, error) {
@@ -63,17 +71,8 @@ func scanUser(row pgx.Row) (*models.User, error) {
&u.Username,
&u.PasswordHash,
&u.LocalPasswordLoginEnabled,
&u.Role,
&u.Permissions,
&u.Enabled,
&u.LibraryIDs,
&u.MaxPlaybackQuality,
&u.AccessPolicyRevision,
&u.MaxStreams,
&u.MaxTranscodes,
&u.MaxProfiles,
&u.DownloadAllowed,
&u.DownloadTranscodeAllowed,
&u.CreatedAt,
&u.UpdatedAt,
)
@@ -90,31 +89,11 @@ func scanUser(row pgx.Row) (*models.User, error) {
func scanUsers(rows pgx.Rows) ([]*models.User, error) {
var users []*models.User
for rows.Next() {
var u models.User
err := rows.Scan(
&u.ID,
&u.Email,
&u.Username,
&u.PasswordHash,
&u.LocalPasswordLoginEnabled,
&u.Role,
&u.Permissions,
&u.Enabled,
&u.LibraryIDs,
&u.MaxPlaybackQuality,
&u.AccessPolicyRevision,
&u.MaxStreams,
&u.MaxTranscodes,
&u.MaxProfiles,
&u.DownloadAllowed,
&u.DownloadTranscodeAllowed,
&u.CreatedAt,
&u.UpdatedAt,
)
u, err := scanUser(rows)
if err != nil {
return nil, fmt.Errorf("scanning user row: %w", err)
}
users = append(users, &u)
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating user rows: %w", err)
@@ -122,77 +101,46 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) {
return users, nil
}
// Create inserts a new user with a bcrypt-hashed password and returns the created user.
// hydrate loads the user's group memberships and applies the effective policy.
func (r *UserRepository) hydrate(ctx context.Context, u *models.User) error {
groups, err := r.groups.GroupsForUser(ctx, u.ID)
if err != nil {
return err
}
ApplyEffectivePolicy(u, groups)
u.Groups = groups
return nil
}
// Create inserts a new user with a bcrypt-hashed password and the given group
// memberships, then returns the created user with its effective policy.
func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInput) (*models.User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hashing password: %w", err)
}
// Base columns that are always included.
localPasswordLoginEnabled := true
if input.LocalPasswordLoginEnabled != nil {
localPasswordLoginEnabled = *input.LocalPasswordLoginEnabled
}
permissions := append([]string(nil), input.Permissions...)
if input.Permissions == nil && input.Role != "admin" {
permissions = DefaultUserPermissions()
}
permissions, err = NormalizePermissions(permissions)
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("beginning user create: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"}
args := []any{
var id int
err = tx.QueryRow(ctx, `
INSERT INTO users (email, username, password_hash, local_password_login_enabled)
VALUES ($1, $2, $3, $4)
RETURNING id`,
NormalizeEmail(input.Email),
NormalizeUsername(input.Username),
string(hash),
localPasswordLoginEnabled,
input.Role,
permissions,
input.LibraryIDs,
input.MaxPlaybackQuality,
}
// Optional columns: nil means use DB default.
if input.MaxStreams != nil {
cols = append(cols, "max_streams")
args = append(args, *input.MaxStreams)
}
if input.MaxTranscodes != nil {
cols = append(cols, "max_transcodes")
args = append(args, *input.MaxTranscodes)
}
if input.MaxProfiles != nil {
cols = append(cols, "max_profiles")
args = append(args, *input.MaxProfiles)
}
if input.DownloadAllowed != nil {
cols = append(cols, "download_allowed")
args = append(args, *input.DownloadAllowed)
}
if input.DownloadTranscodeAllowed != nil {
cols = append(cols, "download_transcode_allowed")
args = append(args, *input.DownloadTranscodeAllowed)
}
// Build placeholders: $1, $2, ..., $N
placeholders := make([]string, len(args))
for i := range args {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf("INSERT INTO users (%s) VALUES (%s) RETURNING %s",
strings.Join(cols, ", "),
strings.Join(placeholders, ", "),
allColumns,
)
row := r.pool.QueryRow(ctx, query, args...)
user, err := scanUser(row)
).Scan(&id)
if err != nil {
if isDuplicateKeyError(err) {
return nil, fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
@@ -200,29 +148,67 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu
return nil, fmt.Errorf("creating user: %w", err)
}
return user, nil
if len(input.GroupIDs) > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO user_groups (user_id, group_id)
SELECT $1, unnest($2::int[])
ON CONFLICT DO NOTHING`, id, input.GroupIDs); err != nil {
if isForeignKeyError(err) {
return nil, fmt.Errorf("%w", ErrUnknownGroup)
}
return nil, fmt.Errorf("adding memberships: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("committing user create: %w", err)
}
return r.GetByID(ctx, id)
}
// GetByID retrieves a user by their numeric ID.
func (r *UserRepository) GetByID(ctx context.Context, id int) (*models.User, error) {
query := `SELECT ` + allColumns + ` FROM users WHERE id = $1`
return scanUser(r.pool.QueryRow(ctx, query, id))
user, err := scanUser(r.pool.QueryRow(ctx, query, id))
if err != nil {
return nil, err
}
if err := r.hydrate(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// GetByUsername retrieves a user by their username (case-insensitive).
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) {
query := `SELECT ` + allColumns + ` FROM users WHERE username = $1`
return scanUser(r.pool.QueryRow(ctx, query, NormalizeUsername(username)))
user, err := scanUser(r.pool.QueryRow(ctx, query, NormalizeUsername(username)))
if err != nil {
return nil, err
}
if err := r.hydrate(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// GetByEmail retrieves a user by their email address (case-insensitive).
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
query := `SELECT ` + allColumns + ` FROM users WHERE email = $1`
return scanUser(r.pool.QueryRow(ctx, query, NormalizeEmail(email)))
user, err := scanUser(r.pool.QueryRow(ctx, query, NormalizeEmail(email)))
if err != nil {
return nil, err
}
if err := r.hydrate(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// Update modifies a user's fields. Only non-nil fields in the input are updated.
// If the input contains a Password, it is bcrypt-hashed before storage.
// If the input contains a Password, it is bcrypt-hashed before storage. A
// non-nil GroupIDs replaces the user's group memberships.
func (r *UserRepository) Update(ctx context.Context, id int, input models.UpdateUserInput) error {
setClauses := []string{}
accessPolicyPredicates := []string{}
@@ -253,113 +239,107 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update
args = append(args, *input.LocalPasswordLoginEnabled)
argIndex++
}
if input.Role != nil {
setClauses = append(setClauses, fmt.Sprintf("role = $%d", argIndex))
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("role IS DISTINCT FROM $%d", argIndex))
args = append(args, *input.Role)
argIndex++
}
if input.Permissions != nil {
permissions, err := NormalizePermissions(*input.Permissions)
if err != nil {
return err
}
setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex))
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("permissions IS DISTINCT FROM $%d", argIndex))
args = append(args, permissions)
argIndex++
}
if input.Enabled != nil {
setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", argIndex))
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("enabled IS DISTINCT FROM $%d", argIndex))
args = append(args, *input.Enabled)
argIndex++
}
if input.LibraryIDs != nil {
setClauses = append(setClauses, fmt.Sprintf("library_ids = $%d", argIndex))
// Library scope is resolved from users.library_ids on each request, so
// changing it must not invalidate durable profile/session tokens.
args = append(args, *input.LibraryIDs)
argIndex++
// Identity and membership writes share one transaction so a failure in
// either (e.g. ErrLastAdministrator) leaves the user fully unchanged.
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning user update: %w", err)
}
if input.MaxPlaybackQuality != nil {
setClauses = append(setClauses, fmt.Sprintf("max_playback_quality = $%d", argIndex))
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("max_playback_quality IS DISTINCT FROM $%d", argIndex))
args = append(args, *input.MaxPlaybackQuality)
argIndex++
}
if input.MaxStreams != nil {
setClauses = append(setClauses, fmt.Sprintf("max_streams = $%d", argIndex))
args = append(args, *input.MaxStreams)
argIndex++
}
if input.MaxTranscodes != nil {
setClauses = append(setClauses, fmt.Sprintf("max_transcodes = $%d", argIndex))
args = append(args, *input.MaxTranscodes)
argIndex++
}
if input.MaxProfiles != nil {
setClauses = append(setClauses, fmt.Sprintf("max_profiles = $%d", argIndex))
args = append(args, *input.MaxProfiles)
argIndex++
}
if input.DownloadAllowed != nil {
setClauses = append(setClauses, fmt.Sprintf("download_allowed = $%d", argIndex))
args = append(args, *input.DownloadAllowed)
argIndex++
}
if input.DownloadTranscodeAllowed != nil {
setClauses = append(setClauses, fmt.Sprintf("download_transcode_allowed = $%d", argIndex))
args = append(args, *input.DownloadTranscodeAllowed)
argIndex++
defer func() { _ = tx.Rollback(ctx) }()
// Disabling the last enabled administrator would brick admin access just
// like removing their membership, so the same transactional guard applies.
// It runs before the identity UPDATE so the administrators group row is
// always locked before the user row (replaceUserGroupsTx re-locks the same
// group row later in this transaction, which is a no-op).
if input.Enabled != nil && !*input.Enabled {
if err := lastEnabledAdministratorGuard(ctx, tx, id); err != nil {
return err
}
}
if len(setClauses) == 0 {
// Nothing to update; still verify the user exists.
_, err := r.GetByID(ctx, id)
return err
}
if len(accessPolicyPredicates) > 0 {
setClauses = append(setClauses, fmt.Sprintf(
"access_policy_revision = CASE WHEN %s THEN access_policy_revision + 1 ELSE access_policy_revision END",
strings.Join(accessPolicyPredicates, " OR "),
))
}
// Always bump updated_at.
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE users SET %s WHERE id = $%d",
strings.Join(setClauses, ", "), argIndex)
args = append(args, id)
tag, err := r.pool.Exec(ctx, query, args...)
if err != nil {
if isDuplicateKeyError(err) {
return fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
// Nothing to update on the users row; still verify the user exists.
var exists int
if err := tx.QueryRow(ctx, `SELECT 1 FROM users WHERE id = $1`, id).Scan(&exists); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("checking user exists: %w", err)
}
} else {
if len(accessPolicyPredicates) > 0 {
setClauses = append(setClauses, fmt.Sprintf(
"access_policy_revision = CASE WHEN %s THEN access_policy_revision + 1 ELSE access_policy_revision END",
strings.Join(accessPolicyPredicates, " OR "),
))
}
// Always bump updated_at.
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE users SET %s WHERE id = $%d",
strings.Join(setClauses, ", "), argIndex)
args = append(args, id)
tag, err := tx.Exec(ctx, query, args...)
if err != nil {
if isDuplicateKeyError(err) {
return fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
}
return fmt.Errorf("updating user: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return fmt.Errorf("updating user: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
if input.GroupIDs != nil {
if err := r.groups.replaceUserGroupsTx(ctx, tx, id, *input.GroupIDs); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing user update: %w", err)
}
return nil
}
// Delete removes a user by their ID.
// Delete removes a user by their ID. Deleting the last enabled administrator
// is rejected with ErrLastAdministrator; the guard and the delete share one
// transaction so concurrent admin mutations serialize on the administrators
// group row.
func (r *UserRepository) Delete(ctx context.Context, id int) error {
tag, err := r.pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id)
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning user delete: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lastEnabledAdministratorGuard(ctx, tx, id); err != nil {
return err
}
tag, err := tx.Exec(ctx, "DELETE FROM users WHERE id = $1", id)
if err != nil {
return fmt.Errorf("deleting user: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing user delete: %w", err)
}
return nil
}
@@ -372,7 +352,27 @@ func (r *UserRepository) List(ctx context.Context) ([]*models.User, error) {
}
defer rows.Close()
return scanUsers(rows)
users, err := scanUsers(rows)
if err != nil {
return nil, err
}
// Bulk-hydrate effective policy with a single membership query.
userIDs := make([]int, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
groupsByUser, err := r.groups.GroupsForUsers(ctx, userIDs)
if err != nil {
return nil, err
}
for _, u := range users {
groups := groupsByUser[u.ID]
ApplyEffectivePolicy(u, groups)
u.Groups = groups
}
return users, nil
}
// Count returns the number of users in the database.
@@ -393,6 +393,15 @@ func isDuplicateKeyError(err error) bool {
return false
}
// isForeignKeyError checks if the error is a PostgreSQL foreign_key_violation (code 23503).
func isForeignKeyError(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23503"
}
return false
}
// extractConstraint extracts the constraint name from a PgError for diagnostic messages.
func extractConstraint(err error) string {
var pgErr *pgconn.PgError
+43 -11
View File
@@ -35,6 +35,33 @@ type SettingsGetter interface {
Get(ctx context.Context, key string) (string, error)
}
// Legacy role strings exposed in API response bodies. Presentation-only;
// intentionally distinct from Permission values even though "admin" overlaps
// textually.
const (
roleAdmin = "admin"
roleUser = "user"
)
// RoleForUser derives the legacy role string exposed in API response bodies
// from the user's group-derived admin flag. It is never used for
// authorization decisions and is no longer carried in JWT claims.
func RoleForUser(u *models.User) string {
if u != nil && u.IsAdmin {
return roleAdmin
}
return roleUser
}
// groupResolverFor returns the user repository's group repository as a
// GroupResolver, avoiding a typed-nil interface when users is nil.
func groupResolverFor(users *UserRepository) GroupResolver {
if users == nil {
return nil
}
return users.Groups()
}
type claimsContextKey struct{}
// WithClaims stores auth claims on the context for auth-owned flows.
@@ -100,7 +127,7 @@ func NewService(
settings: settings,
providers: map[string]AuthProvider{},
metadata: map[string]LoginProviderInfo{},
accounts: NewAccountProvisioner(users, storeProvider),
accounts: NewAccountProvisioner(users, storeProvider, settings, groupResolverFor(users)),
}
if provider != nil {
service.RegisterProvider(LoginProviderInfo{
@@ -209,7 +236,6 @@ func (s *Service) CompleteOAuthLogin(ctx context.Context, in OAuthLoginInput) (*
}
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
@@ -271,7 +297,6 @@ func (s *Service) loginWithProvider(
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
@@ -306,12 +331,20 @@ func (s *Service) SetupInitialUser(
return nil, nil, ErrSetupAlreadyComplete
}
adminGroup, err := s.users.Groups().GetBySlug(ctx, models.GroupSlugAdministrators)
if err != nil {
return nil, nil, fmt.Errorf("loading administrators group: %w", err)
}
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
User: models.CreateUserInput{
Username: username,
Email: email,
Password: password,
Role: "admin",
// Administrators membership is written inside the same transaction
// as the user row, so a failure leaves setup fully retryable. The
// explicit list also keeps configured signup defaults from applying.
GroupIDs: []int{adminGroup.ID},
},
DefaultProfile: DefaultProfileOptions{
Enabled: createDefaultProfile,
@@ -322,6 +355,7 @@ func (s *Service) SetupInitialUser(
}
// Reuse the standard login flow so setup creates a normal session pair.
// Login reloads the user, so the returned user carries the admin policy.
return s.Login(ctx, username, password, deviceName, ip)
}
@@ -352,13 +386,13 @@ func (s *Service) Signup(
return nil, nil, err
}
// Create the user with standard role and access to all libraries.
// Create the user; nil GroupIDs lets the provisioner resolve the
// configured default groups.
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
User: models.CreateUserInput{
Username: username,
Email: email,
Password: password,
Role: "user",
},
DefaultProfile: DefaultProfileOptions{
Enabled: createDefaultProfile,
@@ -412,7 +446,7 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
}
return nil, nil, nil, fmt.Errorf("getting admin user: %w", err)
}
if admin.Role != "admin" || !admin.Enabled {
if !admin.IsAdmin || !admin.Enabled {
return nil, nil, nil, ErrImpersonationNotAllowed
}
if adminUserID == targetUserID {
@@ -423,7 +457,7 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
if err != nil {
return nil, nil, nil, fmt.Errorf("getting target user: %w", err)
}
if !target.Enabled || target.Role == "admin" {
if !target.Enabled || target.IsAdmin {
return nil, nil, nil, ErrImpersonationNotAllowed
}
@@ -446,7 +480,6 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
pair, err := s.generateTokenPair(Claims{
UserID: target.ID,
Role: target.Role,
SessionID: sessionID,
ImpersonatorUserID: &impersonatorUserID,
})
@@ -519,7 +552,6 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair,
return s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: session.ID,
ImpersonatorUserID: session.ImpersonatorUserID,
})
@@ -537,7 +569,7 @@ func (s *Service) validateImpersonator(ctx context.Context, impersonatorUserID *
}
return fmt.Errorf("getting impersonator user: %w", err)
}
if !impersonator.Enabled || impersonator.Role != "admin" {
if !impersonator.Enabled || !impersonator.IsAdmin {
return ErrSessionRevoked
}
return nil
@@ -91,16 +91,6 @@ var SensitiveSettingKeys = map[string]bool{
// still referenced by older request_integrations rows until backfilled).
"requests.radarr.api_key": true,
"requests.sonarr.api_key": true,
// Push notification provider credentials.
"push.apns.p8_key": true,
"push.apns.key_id": true,
"push.apns.team_id": true,
"push.apns.bundle_id": true,
"push.fcm.service_account_json": true,
"push.webpush.vapid_private": true,
"push.webpush.vapid_public": true,
"push.webpush.subject": true,
}
// EncryptedSettingsRepo decorates a raw settings store, transparently
+13 -5
View File
@@ -127,15 +127,16 @@ type userDBConfigRaw struct {
// ScannerConfig holds media scanner settings.
type ScannerConfig struct {
Workers int `yaml:"workers"`
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
EmptyTrashAfterScan bool `yaml:"-"`
FileRemovalGrace time.Duration `yaml:"-"`
Workers int `yaml:"workers"`
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
EmptyTrashAfterScan bool `yaml:"-"`
}
// scannerConfigRaw is the raw YAML representation with duration strings.
type scannerConfigRaw struct {
FileRemovalGrace string `yaml:"file_removal_grace"` // legacy; preserved on YAML import only
FileRemovalGrace string `yaml:"file_removal_grace"`
Workers int `yaml:"workers"`
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
@@ -164,6 +165,9 @@ type PlaybackConfig struct {
ChapterThumbnailExecution string `yaml:"chapter_thumbnail_execution"`
ChapterThumbnailNodeCapacity int `yaml:"chapter_thumbnail_node_capacity"`
TranscodeEnabled bool `yaml:"transcode_enabled"`
AllowHEVCEncoding bool `yaml:"allow_hevc_encoding"`
TranscodeAheadSegments int `yaml:"transcode_ahead_segments"`
SegmentDuration int `yaml:"segment_duration"`
}
// RedisConfig holds Redis connection settings.
@@ -410,6 +414,7 @@ func setDefaults() *configRaw {
StaleGraceSeconds: 120,
},
Scanner: scannerConfigRaw{
FileRemovalGrace: "24h",
Workers: 8,
MaxConcurrentLibraries: 1,
MaxConcurrentScoped: 2,
@@ -427,6 +432,9 @@ func setDefaults() *configRaw {
ChapterThumbnailExecution: "local",
ChapterThumbnailNodeCapacity: 1,
TranscodeEnabled: true,
AllowHEVCEncoding: false,
TranscodeAheadSegments: 30,
SegmentDuration: 6,
},
RateLimit: RateLimitConfig{
Enabled: true,
+20
View File
@@ -253,6 +253,11 @@ func LoadFromDB(m map[string]string) (*Config, error) {
return nil, err
}
cfg.Scanner.MaxConcurrentScoped = maxConcurrentScoped
fileRemovalGrace, err := durationOr(m, "scanner.file_removal_grace", 24*time.Hour)
if err != nil {
return nil, err
}
cfg.Scanner.FileRemovalGrace = fileRemovalGrace
emptyTrash, err := boolOr(m, "scanner.empty_trash_after_scan", true)
if err != nil {
return nil, err
@@ -310,6 +315,21 @@ func LoadFromDB(m map[string]string) (*Config, error) {
return nil, err
}
cfg.Playback.TranscodeEnabled = transcodeEnabled
allowHEVC, err := boolOr(m, "playback.allow_hevc_encoding", false)
if err != nil {
return nil, err
}
cfg.Playback.AllowHEVCEncoding = allowHEVC
transcodeAheadSegments, err := intOr(m, "playback.transcode_ahead_segments", 30)
if err != nil {
return nil, err
}
cfg.Playback.TranscodeAheadSegments = transcodeAheadSegments
segmentDuration, err := intOr(m, "playback.segment_duration", 6)
if err != nil {
return nil, err
}
cfg.Playback.SegmentDuration = segmentDuration
// Redis
cfg.Redis.URL = stringOr(m, "redis.url", "")
-98
View File
@@ -1,98 +0,0 @@
package config
import "strings"
// restartRequiredKeys lists server_settings keys whose values are captured at
// process startup (listeners, connection pools, HTTP clients, worker pools)
// and cannot take effect until the server restarts.
//
// Keys absent from this map and from restartRequiredPrefixes apply without a
// restart: they are either read live from the settings repo at request time
// (overlays, branding, markers, download.*, ...) or hot-reloaded through the
// nodeconfig watcher. When converting a frozen consumer to the live config,
// remove its key here — the admin UI restart banner is driven by this
// registry.
var restartRequiredKeys = map[string]bool{
// Process & logging. log_level/log_quiet hot-reload via the config
// watcher (shared slog.LevelVar + logfilter.SetQuiet).
"server.listen": true,
"server.mode": true,
"server.log_format": true,
"opslog.capture_level": true,
// Auth. The JWT secret is baked into token services and stream signers;
// the expiry durations hot-reload (new tokens only).
"auth.jwt_secret": true,
// Rate limiting gates middleware construction; tier/limit values inside
// the dedicated /admin/rate-limits endpoint hot-reload and report their
// own restart state.
"ratelimit.enabled": true,
"ratelimit.backend": true,
// Playback transcode infrastructure. The playback/stream handlers read
// ffmpeg path and hwaccel live (new transcode sessions), but several
// startup-built consumers still freeze them (scanner ffprobe, chapter
// thumbnails, audiobook enricher) — keep restart-required until those
// convert. transcode_dir is fully live (only the playback handler reads
// it). The chapter-thumbnail worker pool is sized at construction.
"playback.ffmpeg_path": true,
"playback.hw_accel": true,
"playback.hw_device": true,
"playback.chapter_thumbnail_workers": true,
// Scanner / matcher toggles captured at construction. Worker counts,
// batch size, metadata.cache_images, and mdblist.api_key hot-reload via
// atomic setters wired to the config watcher.
"scanner.max_concurrent_libraries": true,
"scanner.max_concurrent_scoped": true,
"scanner.empty_trash_after_scan": true,
"matcher.enable_tv_series_root_queue": true,
"matcher.enable_tv_series_group_queue": true,
// External API clients built once at startup.
"tmdb.api_key": true,
// Compat listeners and session stores.
"audiobookshelf_compat.enabled": true,
// public_url / server_name / emulated_server_version are read live per
// request; server_id is generate-once and baked into the resource
// mapper; the session TTLs are baked into store constructors.
"jellyfin_compat.listen": true,
"jellyfin_compat.server_id": true,
"jellyfin_compat.web_version": true,
"jellyfin_compat.web_dir": true,
"jellyfin_compat.session_ttl": true,
"jellyfin_compat.playback_session_ttl": true,
// AI: connection settings, models, toggles, and quotas hot-reload via
// UpdateConfig/setters wired to the config watcher. Only the dispatch
// semaphore (ai.max_concurrent_jobs, plus its legacy alias) is a
// fixed-capacity channel sized at construction.
"ai.max_concurrent_jobs": true,
"subtitle_ai.max_concurrent_jobs": true,
}
// restartRequiredPrefixes covers whole namespaces of infrastructure settings:
// connection pools and storage clients that are constructed once at startup.
var restartRequiredPrefixes = []string{
"database.",
"userdb.",
"s3.",
"redis.",
"recommendations.",
}
// RestartRequired reports whether changing the given server_settings key
// requires a server restart to take effect.
func RestartRequired(key string) bool {
if restartRequiredKeys[key] {
return true
}
for _, prefix := range restartRequiredPrefixes {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}
-41
View File
@@ -1,41 +0,0 @@
package config
import "testing"
func TestRestartRequired(t *testing.T) {
cases := []struct {
key string
want bool
}{
// Explicit infrastructure keys.
{"server.listen", true},
{"auth.jwt_secret", true},
{"ratelimit.backend", true},
// Prefix-covered namespaces.
{"database.max_connections", true},
{"userdb.backend", true},
{"s3.public_endpoint", true},
{"redis.url", true},
{"recommendations.enabled", true},
// Legacy AI aliases classify like their canonical keys.
{"subtitle_ai.max_concurrent_jobs", true},
{"subtitle_ai.base_url", false}, // like ai.base_url — hot-reloaded
{"ai.api_key", false},
{"subtitle_ai.enabled", false},
{"metadata_ai.on_view", false},
// Live settings read from the settings repo per request.
{"branding.server_name", false},
{"overlays.enabled", false},
{"markers.mode", false},
{"download.enabled", false},
{"allow_4k_transcode", false},
{"defaults.card_overlays", false},
// Unknown keys default to live.
{"some.future_setting", false},
}
for _, tc := range cases {
if got := RestartRequired(tc.key); got != tc.want {
t.Errorf("RestartRequired(%q) = %v, want %v", tc.key, got, tc.want)
}
}
}
+7
View File
@@ -181,6 +181,13 @@ func YAMLToSettingsMap(path string) (map[string]string, error) {
m["playback.chapter_thumbnail_node_capacity"] = strconv.Itoa(raw.Playback.ChapterThumbnailNodeCapacity)
}
m["playback.transcode_enabled"] = strconv.FormatBool(raw.Playback.TranscodeEnabled)
m["playback.allow_hevc_encoding"] = strconv.FormatBool(raw.Playback.AllowHEVCEncoding)
if raw.Playback.TranscodeAheadSegments != 0 {
m["playback.transcode_ahead_segments"] = strconv.Itoa(raw.Playback.TranscodeAheadSegments)
}
if raw.Playback.SegmentDuration != 0 {
m["playback.segment_duration"] = strconv.Itoa(raw.Playback.SegmentDuration)
}
// Redis
m["redis.url"] = raw.Redis.URL
-4
View File
@@ -16,8 +16,6 @@ const (
ChannelHistoryImport EventChannel = "history_import"
ChannelUserState EventChannel = "user_state"
ChannelPlugins EventChannel = "plugins"
ChannelNotifications EventChannel = "notifications"
ChannelRequests EventChannel = "requests"
)
var AllChannels = []EventChannel{
@@ -29,8 +27,6 @@ var AllChannels = []EventChannel{
ChannelHistoryImport,
ChannelUserState,
ChannelPlugins,
ChannelNotifications,
ChannelRequests,
}
type Envelope struct {
+4 -4
View File
@@ -15,10 +15,10 @@ type ScopeResolver interface {
}
// NewScopeAccessFilter returns an AccessFilterResolver backed by the shared
// access scope resolver, so account-level library restrictions
// (users.library_ids), profile library restrictions, user-disabled
// libraries, content-rating ceilings, and playback-quality ceilings all
// apply to the Jellyfin-compat API exactly as they do to the native API.
// access scope resolver, so account-level library restrictions (derived from
// group membership), profile library restrictions, user-disabled libraries,
// content-rating ceilings, and playback-quality ceilings all apply to the
// Jellyfin-compat API exactly as they do to the native API.
//
// PIN verification is skipped because compat login already verified the
// profile PIN (password#pin convention). Resolution failures fail closed:
+55 -8
View File
@@ -26,12 +26,15 @@ const (
type Authenticator struct {
sessions *SessionStore
authService *auth.Service
users auth.UserLoader
now func() time.Time
}
// NewAuthenticator creates a new compat authenticator.
func NewAuthenticator(sessions *SessionStore, authService *auth.Service) *Authenticator {
return &Authenticator{sessions: sessions, authService: authService, now: time.Now}
// NewAuthenticator creates a new compat authenticator. users re-validates the
// session's account on every request (see compatUserEnabled); a nil loader
// fails closed.
func NewAuthenticator(sessions *SessionStore, authService *auth.Service, users auth.UserLoader) *Authenticator {
return &Authenticator{sessions: sessions, authService: authService, users: users, now: time.Now}
}
// ExtractToken extracts a compat token from Jellyfin-style request auth.
@@ -94,6 +97,19 @@ func (a *Authenticator) RequireSession(next http.Handler) http.Handler {
return
}
// Compat sessions cache only identity, never account status: the user
// is re-loaded on EVERY request so disabling (or deleting) an account
// cuts off Jellyfin clients on the next request rather than at
// compat-session expiry — matching the native API and the admin
// API-key path. The session itself is left in place: transient lookup
// failures also deny (fail closed), and deleting here would force a
// re-login after a recovered DB blip. Session eviction on disable is
// handled separately (OnUserSessionsRevoked).
if !compatUserEnabled(r.Context(), a.users, session.StreamAppUserID) {
writeError(w, http.StatusUnauthorized, "Unauthorized", "User account is disabled")
return
}
// Refresh underlying Silo tokens if they're about to expire.
// Use a detached context so a client aborting the request mid-refresh
// (common on flaky mobile networks) doesn't revoke the compat session.
@@ -148,15 +164,46 @@ func serveWithSession(next http.Handler, w http.ResponseWriter, r *http.Request,
next.ServeHTTP(w, r.WithContext(ctx))
}
// compatUserEnabled reports whether a compat session's account still exists
// and is enabled. The user is loaded fresh on every request — never cached on
// the session — so disabling an account takes effect on the next request.
// Every failure mode fails closed: a missing user is a definitive deny, and
// an unexpected lookup error denies rather than serving content for an
// account whose status cannot be verified. A nil loader also fails closed;
// production wiring always provides one alongside the database.
func compatUserEnabled(ctx context.Context, users auth.UserLoader, userID int) bool {
if users == nil {
return false
}
user, err := users.GetByID(ctx, userID)
if err != nil {
if !auth.IsNotFound(err) {
slog.Warn("jellycompat auth: user status check failed; denying request",
"user_id", userID,
"error", err,
)
}
return false
}
return user != nil && user.Enabled
}
// resolveCompatToken resolves a token to a compat session: a session-store token
// (normal login) or, matching Jellyfin, an sa_ admin API key (synthesized
// session bound to the key user's primary profile). Returns false when the token
// matches neither. keyAuth may be nil (resolveSession handles a nil receiver).
func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *AdminAPIKeyAuthenticator, token string) (*Session, bool) {
// matches neither or when the session's account is no longer enabled. keyAuth
// may be nil (resolveSession handles a nil receiver).
func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *AdminAPIKeyAuthenticator, users auth.UserLoader, token string) (*Session, bool) {
if token == "" {
return nil, false
}
if session, ok := sessions.Get(token); ok {
// sa_-key sessions below re-validate the owning user inside
// resolveSession; session-store sessions must do it here so a
// disabled account cannot keep streaming until session expiry.
if !compatUserEnabled(ctx, users, session.StreamAppUserID) {
return nil, false
}
return session, true
}
if strings.HasPrefix(token, "sa_") {
@@ -170,13 +217,13 @@ func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *Ad
// PlaybackSessionAuth creates middleware that falls back to playback session
// authentication for media stream endpoints where external players (e.g. libmpv)
// don't forward auth headers or query parameters.
func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator) func(next http.Handler) http.Handler {
func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator, users auth.UserLoader) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try standard token auth first — a compat session token or an sa_
// admin key (synthesized session).
if token, ok := ExtractToken(r); ok {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, token); ok {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, users, token); ok {
serveWithSession(next, w, r, session)
return
}
@@ -193,7 +240,7 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
// miss it and 401 the stream — forcing a needless transcode fallback.
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
if playSession, found := playbackStore.Get(playSessionID); found {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, users, playSession.CompatToken); ok {
serveWithSession(next, w, r, session)
return
}
+9 -7
View File
@@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -22,13 +23,9 @@ type apiKeyValidator interface {
UpdateLastUsed(ctx context.Context, id int64) error
}
type apiKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
type AdminAPIKeyAuthenticator struct {
keys apiKeyValidator
users apiKeyUserLoader
users auth.UserLoader
provider userstore.UserStoreProvider
now func() time.Time
@@ -53,7 +50,12 @@ type adminAPIKeyAuthResult struct {
// when present, an admin key can synthesize a compat session (see
// resolveSession); when nil, only the admin-bool path (RequireAdminAPIKey /
// RequireSessionOrAdminAPIKey) is available.
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator {
//
// users loads Silo users by ID with their group-derived effective policy
// hydrated (IsAdmin, DownloadAllowed, LibraryIDs). Policy is always read from
// a freshly loaded user — never cached on sessions — so group edits and role
// changes take effect on the next request.
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users auth.UserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator {
if keys == nil || users == nil {
return nil
}
@@ -172,7 +174,7 @@ func (a *AdminAPIKeyAuthenticator) validate(ctx context.Context, token string) (
if err != nil || user == nil || !user.Enabled {
return nil, nil, unauthorized
}
if user.Role != "admin" {
if !user.IsAdmin {
return nil, nil, adminAPIKeyAuthResult{
status: http.StatusForbidden,
code: "Forbidden",
@@ -48,7 +48,7 @@ func (p *fakeUserStoreProvider) Close() error { return nil }
func adminKeyAuthWithPrimary(t *testing.T, clock func() time.Time) (*AdminAPIKeyAuthenticator, *fakeUserStoreProvider, *fakeAPIKeyValidator) {
t.Helper()
validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", Role: "admin", Enabled: true}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", IsAdmin: true, Enabled: true}}
provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{
{ID: "kids", Name: "Kids", IsPrimary: false},
{ID: "p1", Name: "Parent", IsPrimary: true},
@@ -115,7 +115,7 @@ func TestRequireSessionOrAPIKeySession_RejectsNonAdminKey(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Role: "user", Enabled: true}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Enabled: true}}
provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{{ID: "p1", IsPrimary: true}}}}
keyAuth := NewAdminAPIKeyAuthenticator(validator, users, provider, clock)
sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)}
@@ -165,7 +165,7 @@ func TestRequireSessionOrAPIKeySession_FallsThroughWhenSynthesisUnavailable(t *t
"nil authenticator": nil,
"nil provider": NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
nil, clock,
),
}
@@ -254,7 +254,7 @@ func TestPlaybackSessionAuth_APIKeyViaPlaySessionId(t *testing.T) {
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"})
var got *Session
h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := PlaybackSessionAuth(sessions, playback, keyAuth, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = SessionFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))
@@ -283,7 +283,7 @@ func TestPlaybackSessionAuth_RevokedAPIKeyViaPlaySessionId(t *testing.T) {
playback := NewPlaybackSessionStore(time.Hour, clock)
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"})
h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
h := PlaybackSessionAuth(sessions, playback, keyAuth, nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler should not run for a revoked key")
}))
req := httptest.NewRequest(http.MethodGet, "/Videos/x/hls/p/seg0.ts?PlaySessionId=ps1", nil)
@@ -315,8 +315,9 @@ func TestValidatePseudoUser(t *testing.T) {
}
func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), Username: "admin"}
h := NewAuthHandler(&config.Config{}, nil, nil,
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", IsAdmin: true, Enabled: true}})
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), StreamAppUserID: 2, Username: "admin"}
req := httptest.NewRequest(http.MethodGet, "/Users", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, session))
@@ -342,7 +343,7 @@ func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
}
func TestHandleUsers_RequiresSession(t *testing.T) {
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
h := NewAuthHandler(&config.Config{}, nil, nil, nil)
rec := httptest.NewRecorder()
h.HandleUsers(rec, httptest.NewRequest(http.MethodGet, "/Users", nil))
if rec.Code != http.StatusUnauthorized {
+117 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
@@ -27,7 +28,7 @@ func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) {
StreamAppTokenExpiry: now.Add(3 * time.Minute),
})
authn := &Authenticator{sessions: store, authService: nil}
authn := &Authenticator{sessions: store, authService: nil, users: enabledUserLoader(1)}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "valid-tok")
rec := httptest.NewRecorder()
@@ -62,7 +63,7 @@ func TestRequireSession_PassesThroughNonExpiringToken(t *testing.T) {
StreamAppTokenExpiry: now.Add(30 * time.Minute),
})
authn := &Authenticator{sessions: store}
authn := &Authenticator{sessions: store, users: enabledUserLoader(1)}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "fresh-tok")
rec := httptest.NewRecorder()
@@ -97,7 +98,7 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
StreamAppTokenExpiry: now.Add(-1 * time.Hour), // already expired
})
authn := &Authenticator{sessions: store, authService: nil}
authn := &Authenticator{sessions: store, authService: nil, users: enabledUserLoader(1)}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "expired-tok")
rec := httptest.NewRecorder()
@@ -119,6 +120,75 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
}
}
// enabledUserLoader returns a loader serving a single enabled, non-admin user.
func enabledUserLoader(id int) auth.UserLoader {
return &fakeAPIKeyUserLoader{user: &models.User{ID: id, Enabled: true}}
}
func TestRequireSession_RejectsDisabledUser(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
store := NewSessionStore(30*24*time.Hour, clock)
_ = store.Put(Session{Token: "tok", StreamAppUserID: 1})
cases := map[string]auth.UserLoader{
"disabled account": &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: false}},
"deleted account": &fakeAPIKeyUserLoader{}, // loader returns auth.ErrNotFound
"no loader wired": nil, // fail closed
}
for name, users := range cases {
t.Run(name, func(t *testing.T) {
authn := &Authenticator{sessions: store, users: users}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "tok")
rec := httptest.NewRecorder()
authn.RequireSession(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler should not run for a disabled/missing account")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (body: %s)", rec.Code, rec.Body.String())
}
// The compat session must survive: a transient lookup failure also
// denies, and deleting here would force a re-login after recovery.
if _, ok := store.Get("tok"); !ok {
t.Fatal("compat session must not be deleted by a per-request denial")
}
})
}
}
func TestPlaybackSessionAuth_RejectsDisabledUser(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions := NewSessionStore(30*24*time.Hour, clock)
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
disabled := &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: false}}
mw := PlaybackSessionAuth(sessions, playbackStore, nil, disabled)
// Both the direct-token path and the PlaySessionId fallback must deny.
for name, target := range map[string]string{
"token": "/Videos/itm/stream?api_key=compat-tok",
"play session": "/Videos/itm/stream?PlaySessionId=ps-abc",
"lowercase psi": "/Videos/itm/stream?playSessionId=ps-abc",
} {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest("GET", target, nil)
rec := httptest.NewRecorder()
mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler should not run for a disabled account")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (body: %s)", rec.Code, rec.Body.String())
}
})
}
}
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
@@ -127,7 +197,7 @@ func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
mw := PlaybackSessionAuth(sessions, playbackStore, nil, enabledUserLoader(1))
cases := []struct {
name string
@@ -173,10 +243,48 @@ func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
}
}
func TestUserPolicyFromEffectivePolicy(t *testing.T) {
u := &models.User{
ID: 7, Enabled: true, IsAdmin: false,
LibraryIDs: []int{3, 5},
DownloadAllowed: false,
}
policy := buildUserPolicy(u)
if policy.IsAdministrator {
t.Error("non-admin must not be administrator")
}
if policy.EnableAllFolders {
t.Error("restricted user must not see all folders")
}
if policy.EnableContentDownloading {
t.Error("downloads disabled must map through")
}
// Pin the Jellyfin folder GUID encoding: EncodeNumericID packs the
// EncodedIDLibrary type byte (0x01) into byte 0 and the library ID
// big-endian into bytes 8-15. Clients persist these GUIDs, so the
// encoding must stay stable.
wantFolders := []string{
"01000000-0000-0000-0000-000000000003",
"01000000-0000-0000-0000-000000000005",
}
if !slices.Equal(policy.EnabledFolders, wantFolders) {
t.Errorf("enabled folders = %v, want %v", policy.EnabledFolders, wantFolders)
}
admin := &models.User{ID: 1, Enabled: true, IsAdmin: true, LibraryIDs: nil, DownloadAllowed: true}
adminPolicy := buildUserPolicy(admin)
if !adminPolicy.IsAdministrator || !adminPolicy.EnableAllFolders || !adminPolicy.EnableContentDownloading {
t.Error("admin policy must be unrestricted")
}
if len(adminPolicy.EnabledFolders) != 0 {
t.Errorf("unrestricted policy must not enumerate folders, got %v", adminPolicy.EnabledFolders)
}
}
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -197,7 +305,7 @@ func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Enabled: true}},
)
req := httptest.NewRequest("POST", "/Library/Media/Updated", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -215,7 +323,7 @@ func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{returnNilWithoutError: true},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -241,7 +349,7 @@ func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
return nil
},
},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -266,7 +374,7 @@ func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
// newAdminAPIKeyAuthForTest builds an authenticator without a UserStoreProvider,
// exercising the admin-bool path only (no session synthesis).
func newAdminAPIKeyAuthForTest(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator {
func newAdminAPIKeyAuthForTest(keys apiKeyValidator, users auth.UserLoader) *AdminAPIKeyAuthenticator {
return NewAdminAPIKeyAuthenticator(keys, users, nil, nil)
}
+53 -10
View File
@@ -42,18 +42,10 @@ func TestHandleDownload_ServesOriginalFile(t *testing.T) {
codec: codec,
content: &stubContentService{detail: detail},
fileResolver: testCompatFileResolver{file: &models.MediaFile{ID: 42, FilePath: filePath}},
users: &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: true, DownloadAllowed: true}},
}
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
req := httptest.NewRequest("GET", "/Items/"+encodedID+"/Download", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", encodedID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.HandleDownload(rec, req)
rec := serveDownloadRequest(handler, codec, contentID)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
@@ -66,6 +58,57 @@ func TestHandleDownload_ServesOriginalFile(t *testing.T) {
}
}
// TestHandleDownload_ForbiddenWhenDownloadsDisabled verifies the download
// route enforces the group-derived DownloadAllowed policy from a fresh user
// load and returns 403 for restricted users.
func TestHandleDownload_ForbiddenWhenDownloadsDisabled(t *testing.T) {
codec := NewResourceIDCodec()
handler := &PlaybackHandler{
codec: codec,
content: &stubContentService{},
users: &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: true, DownloadAllowed: false}},
}
rec := serveDownloadRequest(handler, codec, "movie-1")
if rec.Code != 403 {
t.Fatalf("expected status 403; got %d, body=%s", rec.Code, rec.Body.String())
}
}
// TestHandleDownload_FailsClosedWhenUserLoadFails verifies a user-load error
// never falls back to permissive defaults.
func TestHandleDownload_FailsClosedWhenUserLoadFails(t *testing.T) {
codec := NewResourceIDCodec()
handler := &PlaybackHandler{
codec: codec,
content: &stubContentService{},
users: &fakeAPIKeyUserLoader{}, // GetByID errors for every ID
}
rec := serveDownloadRequest(handler, codec, "movie-1")
if rec.Code != 500 {
t.Fatalf("expected status 500; got %d, body=%s", rec.Code, rec.Body.String())
}
}
// serveDownloadRequest issues GET /Items/{id}/Download for user 1 against
// the handler and returns the recorded response.
func serveDownloadRequest(handler *PlaybackHandler, codec *ResourceIDCodec, contentID string) *httptest.ResponseRecorder {
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
req := httptest.NewRequest("GET", "/Items/"+encodedID+"/Download", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", encodedID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.HandleDownload(rec, req)
return rec
}
// TestItemDetail_AdvertisesCanDownload guards against regressing the
// CanDownload flag: Infuse refuses Direct Play (Static=true streaming) of
// items it believes it cannot download, so playable items must advertise it.
+110 -48
View File
@@ -3,13 +3,18 @@ package jellycompat
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
)
type authenticateByNameRequest struct {
@@ -115,18 +120,20 @@ type loginResolver interface {
// AuthHandler serves Jellyfin login/current-user routes.
type AuthHandler struct {
cfg func() *config.Config
cfg *config.Config
loginResolver loginResolver
authenticator *Authenticator
users auth.UserLoader
}
// NewAuthHandler creates a new auth handler. The config provider is invoked
// per request so compat setting changes apply without restart.
func NewAuthHandler(cfg func() *config.Config, loginResolver loginResolver, authenticator *Authenticator) *AuthHandler {
// NewAuthHandler creates a new auth handler. users supplies the freshly
// loaded effective policy for user DTOs; user routes fail when it is absent.
func NewAuthHandler(cfg *config.Config, loginResolver loginResolver, authenticator *Authenticator, users auth.UserLoader) *AuthHandler {
return &AuthHandler{
cfg: cfg,
loginResolver: loginResolver,
authenticator: authenticator,
users: users,
}
}
@@ -159,10 +166,17 @@ func (h *AuthHandler) HandleAuthenticateByName(w http.ResponseWriter, r *http.Re
return
}
dto, err := h.userDTO(r.Context(), session)
if err != nil {
slog.Error("jellycompat login user dto failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, authenticateByNameResponse{
AccessToken: session.Token,
ServerID: h.cfg().JellyfinCompat.ServerID,
User: h.userDTO(session),
ServerID: h.cfg.JellyfinCompat.ServerID,
User: dto,
SessionInfo: h.sessionInfo(session),
})
}
@@ -174,7 +188,13 @@ func (h *AuthHandler) HandleCurrentUser(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
writeJSON(w, http.StatusOK, h.userDTO(session))
dto, err := h.userDTO(r.Context(), session)
if err != nil {
slog.Error("jellycompat current user dto failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, dto)
}
// HandleUsers serves GET /Users.
@@ -191,7 +211,13 @@ func (h *AuthHandler) HandleUsers(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
writeJSON(w, http.StatusOK, []userDTOResponse{h.userDTO(session)})
dto, err := h.userDTO(r.Context(), session)
if err != nil {
slog.Error("jellycompat users list dto failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, []userDTOResponse{dto})
}
// HandleUserByID serves GET /Users/{id}.
@@ -206,7 +232,13 @@ func (h *AuthHandler) HandleUserByID(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "NotFound", "User not found")
return
}
writeJSON(w, http.StatusOK, h.userDTO(session))
dto, err := h.userDTO(r.Context(), session)
if err != nil {
slog.Error("jellycompat user by id dto failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, dto)
}
// HandleLogout serves POST /Sessions/Logout.
@@ -220,50 +252,33 @@ func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
name := session.Username
// userDTO builds the Jellyfin user DTO for a compat session. The user is
// loaded fresh on every call so the policy always reflects the current
// group-derived effective policy: compat sessions are long-lived (and
// persisted without policy), so anything cached at login would go stale the
// moment group membership changes. Callers must fail the request on error
// rather than fall back to permissive defaults. These are cold paths (login,
// /Users/Me, /Users listings), so the extra query is fine.
func (h *AuthHandler) userDTO(ctx context.Context, session *Session) (userDTOResponse, error) {
if h.users == nil {
return userDTOResponse{}, errors.New("user loader not configured")
}
user, err := h.users.GetByID(ctx, session.StreamAppUserID)
if err != nil {
return userDTOResponse{}, fmt.Errorf("load user %d: %w", session.StreamAppUserID, err)
}
if user == nil {
return userDTOResponse{}, fmt.Errorf("user %d not found", session.StreamAppUserID)
}
return userDTOResponse{
ID: session.PseudoUserID.String(),
Name: name,
ServerID: h.cfg().JellyfinCompat.ServerID,
Name: session.Username,
ServerID: h.cfg.JellyfinCompat.ServerID,
HasPassword: true,
HasConfiguredPassword: true,
HasConfiguredEasyPassword: false,
Policy: userPolicyResponse{
IsAdministrator: false,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableLyricManagement: false,
IsDisabled: false,
EnableUserPreferenceAccess: true,
EnableRemoteControlOfOtherUsers: false,
EnableSharedDeviceControl: false,
EnableRemoteAccess: true,
EnableLiveTVManagement: false,
EnableLiveTVAccess: false,
EnableMediaPlayback: true,
EnableAudioPlaybackTranscoding: true,
EnableVideoPlaybackTranscoding: true,
EnablePlaybackRemuxing: true,
ForceRemoteSourceTranscoding: false,
EnableContentDeletion: false,
EnableContentDownloading: true,
EnableSyncTranscoding: false,
EnableMediaConversion: false,
EnableAllDevices: true,
EnableAllChannels: false,
EnableAllFolders: true,
InvalidLoginAttemptCount: 0,
LoginAttemptsBeforeLockout: 0,
MaxActiveSessions: 0,
EnablePublicSharing: false,
RemoteClientBitrateLimit: 0,
AuthenticationProviderID: "silo-local",
PasswordResetProviderID: "silo-local",
SyncPlayAccess: "None",
},
Policy: buildUserPolicy(user),
Configuration: userConfigurationResponse{
PlayDefaultAudioTrack: true,
DisplayMissingEpisodes: false,
@@ -279,7 +294,54 @@ func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
RememberSubtitleSelections: true,
EnableNextEpisodeAutoPlay: true,
},
}, nil
}
// buildUserPolicy maps Silo's group-derived effective policy onto the
// Jellyfin UserPolicy shape.
func buildUserPolicy(u *models.User) userPolicyResponse {
policy := userPolicyResponse{
IsAdministrator: u.IsAdmin,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableLyricManagement: false,
IsDisabled: false,
EnableUserPreferenceAccess: true,
EnableRemoteControlOfOtherUsers: false,
EnableSharedDeviceControl: false,
EnableRemoteAccess: true,
EnableLiveTVManagement: false,
EnableLiveTVAccess: false,
EnableMediaPlayback: true,
EnableAudioPlaybackTranscoding: true,
EnableVideoPlaybackTranscoding: true,
EnablePlaybackRemuxing: true,
ForceRemoteSourceTranscoding: false,
EnableContentDeletion: false,
EnableContentDownloading: u.DownloadAllowed,
EnableSyncTranscoding: false,
EnableMediaConversion: false,
EnableAllDevices: true,
EnableAllChannels: false,
EnableAllFolders: u.LibraryIDs == nil,
InvalidLoginAttemptCount: 0,
LoginAttemptsBeforeLockout: 0,
MaxActiveSessions: 0,
EnablePublicSharing: false,
RemoteClientBitrateLimit: 0,
AuthenticationProviderID: "silo-local",
PasswordResetProviderID: "silo-local",
SyncPlayAccess: "None",
}
if u.LibraryIDs != nil {
folders := make([]string, len(u.LibraryIDs))
for i, id := range u.LibraryIDs {
folders[i] = EncodeNumericID(EncodedIDLibrary, uint64(id)).String()
}
policy.EnabledFolders = folders
}
return policy
}
func (h *AuthHandler) sessionInfo(session *Session) *sessionInfoResponse {
@@ -300,6 +362,6 @@ func (h *AuthHandler) sessionInfo(session *Session) *sessionInfoResponse {
SupportsRemoteControl: true,
HasCustomDeviceName: false,
SupportedCommands: []string{},
ServerID: h.cfg().JellyfinCompat.ServerID,
ServerID: h.cfg.JellyfinCompat.ServerID,
}
}
+9 -3
View File
@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
@@ -32,6 +33,10 @@ import (
"github.com/Silo-Server/silo-server/internal/userstore"
)
// audioSpatialFormatNone is the Jellyfin AudioSpatialFormat value for streams
// without spatial audio metadata.
const audioSpatialFormatNone = "None"
type playbackInfoRequest struct {
UserID string `json:"UserId"`
MediaSourceID string `json:"MediaSourceId"`
@@ -119,6 +124,7 @@ type PlaybackHandler struct {
sessionMgr SessionManagerInterface
fileResolver FilePathResolver
storeProvider userstore.UserStoreProvider
users auth.UserLoader // fresh effective-policy loads for download enforcement
NodePlanner nodepool.SessionPlanner
JWTSecret string
profileStaler profileStaler
@@ -664,7 +670,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
PixelFormat: track.PixelFormat,
AudioSpatialFormat: "None",
AudioSpatialFormat: audioSpatialFormatNone,
AverageFrameRate: parseCompatFrameRate(track.FrameRate),
RealFrameRate: parseCompatFrameRate(track.FrameRate),
ReferenceFrameRate: parseCompatFrameRate(track.FrameRate),
@@ -694,7 +700,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
IsHearingImpaired: false,
IsTextSubtitleStream: false,
SupportsExternalStream: false,
AudioSpatialFormat: "None",
AudioSpatialFormat: audioSpatialFormatNone,
Channels: track.Channels,
BitRate: track.Bitrate,
})
@@ -721,7 +727,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
IsHearingImpaired: track.HearingImpaired,
IsTextSubtitleStream: true,
SupportsExternalStream: track.External,
AudioSpatialFormat: "None",
AudioSpatialFormat: audioSpatialFormatNone,
DeliveryURL: subtitleDeliveryURL(routeItemID, mediaSourceID, streamIndex, format, compatToken, playSessionID),
DeliveryMethod: subtitleDeliveryMethod(track.External),
Path: subtitlePath(track, routeItemID, mediaSourceID, streamIndex, format),
+8 -10
View File
@@ -27,13 +27,11 @@ type endpointInfoResponse struct {
// SystemHandler serves Jellyfin setup/system endpoints.
type SystemHandler struct {
cfg func() *config.Config
cfg *config.Config
}
// NewSystemHandler creates a new system handler. The config provider is
// invoked per request so public URL / server name / emulated version
// changes apply without restart.
func NewSystemHandler(cfg func() *config.Config) *SystemHandler {
// NewSystemHandler creates a new system handler.
func NewSystemHandler(cfg *config.Config) *SystemHandler {
return &SystemHandler{cfg: cfg}
}
@@ -62,7 +60,7 @@ func (h *SystemHandler) HandleEndpoint(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, endpointInfoResponse{
IsLocal: true,
IsInNetwork: true,
Address: h.cfg().JellyfinCompat.PublicURL,
Address: h.cfg.JellyfinCompat.PublicURL,
})
}
@@ -73,11 +71,11 @@ func (h *SystemHandler) HandlePing(w http.ResponseWriter, r *http.Request) {
func (h *SystemHandler) systemInfo() publicSystemInfoResponse {
return publicSystemInfoResponse{
LocalAddress: h.cfg().JellyfinCompat.PublicURL,
ServerName: h.cfg().JellyfinCompat.ServerName,
Version: h.cfg().JellyfinCompat.EmulatedServerVersion,
LocalAddress: h.cfg.JellyfinCompat.PublicURL,
ServerName: h.cfg.JellyfinCompat.ServerName,
Version: h.cfg.JellyfinCompat.EmulatedServerVersion,
ProductName: "Jellyfin Server",
ID: h.cfg().JellyfinCompat.ServerID,
ID: h.cfg.JellyfinCompat.ServerID,
StartupWizardCompleted: true,
}
}
+6 -5
View File
@@ -61,12 +61,12 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(requestLoggerMiddleware)
r.Use(middleware.Recoverer)
systemHandler := NewSystemHandler(deps.CurrentConfig)
systemHandler := NewSystemHandler(deps.Config)
webFS, err := resolveCompatWebFS(deps)
if err != nil {
slog.Error("jellycompat web bundle unavailable", "error", err)
}
authHandler := NewAuthHandler(deps.CurrentConfig, deps.LoginResolver, deps.Authenticator)
authHandler := NewAuthHandler(deps.Config, deps.LoginResolver, deps.Authenticator, deps.UserLoader)
nextUpRepo := catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
var subtitleRepo subtitles.Repository
if deps.SubtitleRepo != nil {
@@ -82,7 +82,7 @@ func NewRouter(deps Dependencies) chi.Router {
itemsHandler.posterPresigner = deps.PosterPresigner
itemsHandler.presignTTL = deps.PresignTTL
autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler)
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader, deps.UserStoreProvider, deps.Now)
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.UserLoader, deps.UserStoreProvider, deps.Now)
autoscanVirtualFoldersRegistered := false
if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil {
r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)).
@@ -93,6 +93,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
playbackHandler.users = deps.UserLoader
if deps.DB != nil {
playbackHandler.profileStaler = recommendations.NewRepo(deps.DB)
}
@@ -216,7 +217,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Stream routes: use playback-session auth fallback for media players
// (e.g. libmpv) that don't forward auth headers or query parameters.
r.Group(func(r chi.Router) {
r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore, adminAPIKeyAuth))
r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore, adminAPIKeyAuth, deps.UserLoader))
r.Method(http.MethodHead, "/Items/{id}/Download", http.HandlerFunc(playbackHandler.HandleDownload))
r.Get("/Items/{id}/Download", playbackHandler.HandleDownload)
r.Method(http.MethodHead, "/Videos/{id}/stream", http.HandlerFunc(playbackHandler.HandleVideoStream))
@@ -334,7 +335,7 @@ func withDefaults(deps Dependencies) Dependencies {
}
if deps.Authenticator == nil && deps.SessionStore != nil {
deps.Authenticator = NewAuthenticator(deps.SessionStore, deps.AuthService)
deps.Authenticator = NewAuthenticator(deps.SessionStore, deps.AuthService, deps.UserLoader)
}
return deps
}
+6 -18
View File
@@ -23,10 +23,7 @@ import (
// Dependencies holds the pluggable pieces used by the compat server.
type Dependencies struct {
Config *config.Config
// LiveConfig returns the current hot-reloaded config. May be nil (tests,
// worker modes); read through CurrentConfig(), which falls back to Config.
LiveConfig func() *config.Config
Config *config.Config
DB *pgxpool.Pool
SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set)
ClientIPResolver *clientip.Resolver
@@ -48,9 +45,11 @@ type Dependencies struct {
AuthService *auth.Service
// Autoscan / admin compatibility support.
APIKeyValidator apiKeyValidator
APIKeyUserLoader apiKeyUserLoader
ScanQueue scantrigger.Queuer
APIKeyValidator apiKeyValidator
// UserLoader loads users with their effective policy hydrated. Required
// for API-key auth, user DTO policy, and download permission enforcement.
UserLoader auth.UserLoader
ScanQueue scantrigger.Queuer
// Catalog repos (for ContentService construction)
BrowseRepo *catalog.BrowseRepository
@@ -87,17 +86,6 @@ type Dependencies struct {
S3Bucket string // optional
}
// CurrentConfig returns the live config when hot reload is wired, falling
// back to the startup snapshot otherwise.
func (d *Dependencies) CurrentConfig() *config.Config {
if d.LiveConfig != nil {
if cfg := d.LiveConfig(); cfg != nil {
return cfg
}
}
return d.Config
}
// Server wraps the compat HTTP handler.
type Server struct {
cfg *config.Config
+19
View File
@@ -122,6 +122,10 @@ func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Reque
// load-bearing for Infuse: it refuses Direct Play (Static=true streaming)
// for items it believes it cannot download, so the flag must stay true and
// this route must exist.
//
// The requesting user's group-derived DownloadAllowed policy is enforced
// here from a fresh user load (never a session-cached value), failing closed
// on any load error. For allowed users the route behavior is unchanged.
func (h *PlaybackHandler) HandleDownload(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
@@ -129,6 +133,21 @@ func (h *PlaybackHandler) HandleDownload(w http.ResponseWriter, r *http.Request)
return
}
if h.users == nil {
writeError(w, http.StatusInternalServerError, "ServerError", "User loader not available")
return
}
user, err := h.users.GetByID(r.Context(), session.StreamAppUserID)
if err != nil || user == nil {
slog.Error("jellycompat download user load failed", "user_id", session.StreamAppUserID, "error", err)
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to resolve user")
return
}
if !user.DownloadAllowed {
writeError(w, http.StatusForbidden, "Forbidden", "Downloads are not permitted for this user")
return
}
contentID, err := decodeContentID(h.codec, chiURLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
+9 -26
View File
@@ -4,43 +4,23 @@ import (
"context"
"log/slog"
"strings"
"sync/atomic"
)
// Handler wraps an slog.Handler and drops records whose message starts
// with any of the configured quiet prefixes (e.g. "metadata:").
//
// The prefix list is shared through an atomic pointer so SetQuiet applies
// to every clone produced by WithAttrs/WithGroup, and is safe to call
// concurrently with logging.
type Handler struct {
inner slog.Handler
prefixes *atomic.Pointer[[]string]
prefixes []string
}
// New returns a Handler that delegates to inner but silences messages
// matching any prefix in quietCSV (comma-separated, e.g. "metadata,scanner").
// A colon+space is appended automatically: "metadata" silences "metadata: ...".
// The Handler is always returned (even for an empty quietCSV) so the quiet
// list can be changed later via SetQuiet.
func New(inner slog.Handler, quietCSV string) *Handler {
prefixes := &atomic.Pointer[[]string]{}
parsed := parseQuiet(quietCSV)
prefixes.Store(&parsed)
return &Handler{inner: inner, prefixes: prefixes}
}
// SetQuiet replaces the quiet prefix list. It applies immediately to this
// handler and every WithAttrs/WithGroup clone derived from it.
func (h *Handler) SetQuiet(quietCSV string) {
parsed := parseQuiet(quietCSV)
h.prefixes.Store(&parsed)
}
func parseQuiet(quietCSV string) []string {
// If quietCSV is empty, the inner handler is returned directly.
func New(inner slog.Handler, quietCSV string) slog.Handler {
trimmed := strings.TrimSpace(quietCSV)
if trimmed == "" {
return nil
return inner
}
parts := strings.Split(trimmed, ",")
prefixes := make([]string, 0, len(parts))
@@ -50,7 +30,10 @@ func parseQuiet(quietCSV string) []string {
prefixes = append(prefixes, p+":")
}
}
return prefixes
if len(prefixes) == 0 {
return inner
}
return &Handler{inner: inner, prefixes: prefixes}
}
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
@@ -59,7 +42,7 @@ func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
func (h *Handler) Handle(ctx context.Context, r slog.Record) error {
msg := r.Message
for _, prefix := range *h.prefixes.Load() {
for _, prefix := range h.prefixes {
if strings.HasPrefix(msg, prefix) {
return nil
}

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