Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a50097f27 | ||
|
|
5c950beefe | ||
|
|
767a7ccf24 | ||
|
|
20ed9ecd05 | ||
|
|
43beb4693a | ||
|
|
5f9cfd213b | ||
|
|
40c4d6bcc4 | ||
|
|
076351f3d5 | ||
|
|
3034db6dc8 | ||
|
|
4430eda7a2 | ||
|
|
373939126e | ||
|
|
49e14d3459 | ||
|
|
c26835cd6a | ||
|
|
83a9a1c102 | ||
|
|
e40e2437ef | ||
|
|
5d513c2737 | ||
|
|
6701da1829 | ||
|
|
fcc782fe19 | ||
|
|
18bc4b3f16 | ||
|
|
ac280ab7f3 | ||
|
|
f499d0901b | ||
|
|
2bf4bd127e | ||
|
|
f2c9468122 | ||
|
|
4b70679815 | ||
|
|
0b7156ac0a | ||
|
|
9c0172a706 | ||
|
|
0733e3fc51 | ||
|
|
ecc08b7569 | ||
|
|
0638a514b7 | ||
|
|
e067041b05 | ||
|
|
d934bb8924 | ||
|
|
4d98005a23 |
+20
-119
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]"},
|
||||
}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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 != "") {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user