Compare commits

..
Author SHA1 Message Date
Claude Code 67209e38e0 fix(notifications): make Materializer.Start race-free single-shot
The previous `if m.unsub != nil` check-and-set was unsynchronized, so concurrent
Start calls could double-subscribe and leak an unsubscribe handle. Use a
sync.Once (mirroring stopOnce) so only the first call subscribes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:58:27 +02:00
Claude Code 35b8b9e44e fix(notifications): address PR #132 review feedback
Security / correctness:
- push: HandleToggleDevice now requires an explicit `enabled` (pointer bool) so
  `{}` can't silently disable a device.
- push: ClaimDue only claims deliveries for enabled devices with a token
  (push_enabled = true AND push_token IS NOT NULL).
- push: WebPushConfig.Configured() also requires Subject (avoids empty VAPID sub).
- notifications: SetPreferences persists the whole validated set in one
  transaction and returns a typed ErrInvalidCategory so the handler can 400 on
  invalid input vs 500 on a store failure (no more partial writes / mis-coded
  errors).
- requests: on a hard submission error after approval, publish request.approved
  so consumers aren't left with only request.submitted for an approved row
  (soft-fail behaviour unchanged).
- migration: add push_deliveries (user_id, profile_id) FK to user_profiles.

Robustness / hygiene:
- presence: log a warning when Redis is unavailable before falling back to the
  in-memory registry.
- router: mount push routes only when both PushStore and PushConfig are present
  (avoids nil-config panics).
- notifications: validate request_id before deriving a request DedupRef; make
  Materializer.Start single-shot.
- push: parseRetryAfter accepts the HTTP-date form of Retry-After.
- requests: populate Quality in published lifecycle payloads.
- docs: use repository-relative example paths in the push plans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:17:32 +02:00
Claude Code be704c3a06 refactor(web): dedupe notification components from code review
- Extract a shared NotificationStripBar presentational component; AnnouncementBar
  and NotificationStrip become thin data-fetch wrappers.
- Extract partitionAnnouncementsFirst() (used by the inbox and bell) and a
  notificationTimeAgo() helper.
- Revert the shared timeAgo util to upstream (format relative time in the
  notifications module instead of parameterizing the shared util).
- Remove the no-op useInfiniteList wrapper in the inbox page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:32:24 +02:00
Claude Code dcce156d4a refactor(notifications): security, decoupling, and cleanup from code review
Security:
- WS: never deliver another user's notification/request events to admins
  (personal channels drop the admin cross-user monitoring bypass).
- Scope MarkRead/MarkAllRead/Dismiss to the active profile so one profile
  cannot read/dismiss a sibling profile's notifications.
- Bound announcement title/body and cap the request body; cap push token size.

Decoupling / footprint:
- Password-changed notification is now event-driven: the admin handler publishes
  a user.password_changed event (shared EventUserPasswordChanged constant) and a
  new system matcher materializes it, removing the notifications dependency from
  the admin handler.
- Revert a no-op control-flow rewrite in requests reconcileRequest.

Refactors:
- Promote presence.Registry.Refresh onto the interface (no-op for the memory
  registry) and drop the runtime type-assertion in the WS handler.
- Extract a deviceContext helper for the push register/revoke handlers.
- Thread the claim context into the push worker's outcome writes instead of
  context.Background().

Note: per-profile / child-safe live filtering stays client-side and reactive
(the active profile is not sent on the WS, which never reconnects on switch);
the REST inbox remains the authoritative child-safe boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:32:14 +02:00
Claude Code dcb8ce8afe refactor(push): send FCM via HTTP v1 instead of the firebase SDK
Replace firebase.google.com/go/v4 with direct FCM HTTP v1 REST calls
authenticated by golang.org/x/oauth2/google (already a dependency). This drops
firebase + google.golang.org/api and their large transitive tree
(cloud.google.com/go/*, OpenTelemetry, spiffe, envoyproxy) — go.sum shrinks by
~84 lines — with no behaviour change. Result classification (UNREGISTERED /
INVALID_ARGUMENT → dead; QUOTA_EXCEEDED / UNAVAILABLE → backoff) is preserved as
a pure, unit-tested helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:31:55 +02:00
Claude Code 93eb23d2ec fix(notifications): restore admin announcements list refresh on add/delete
Regression from switching the create/delete invalidation to notificationKeys.all
(["notifications"]). The admin announcements list is keyed under
["admin","announcements"], which that prefix does not match — so the admin page
stopped refreshing on publish/delete (only updating once the 2-min staleTime
lapsed). Invalidate both keys: the admin list and the user-facing notification
tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 20b75732ac fix(notifications): home bars always refetch on mount
Relying on cache invalidation alone left the home announcement bar showing
stale (empty) data when landing on the app after publishing — the cached query
could still be within staleTime and not refetch on remount. Give the home
AnnouncementBar and NotificationStrip staleTime:0 + refetchOnMount:"always" via
a new options arg on useNotificationsList, so navigating to "/" (logo / Back to
App) always pulls current announcements without a manual reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code c47c3dc621 fix(notifications): refresh notification views when an announcement is published/deleted
useCreateAnnouncement/useDeleteAnnouncement only invalidated the admin
announcements list, so after publishing an announcement the admin's own app
(home bar, inbox, bell, unread count) kept showing stale cached data and needed
a manual reload to surface it. Invalidate the whole notifications tree instead,
so the publisher's session refreshes without relying on the live WS event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 84704a073c fix(notifications): refresh per-profile data on profile switch and announcement push
Two stale-cache bugs made announcements look account-wide in the UI even though
each profile now has its own row:

- Switching profiles (selectProfile) swapped the active-profile header but never
  reset the React Query cache, so the new profile kept showing the previous
  profile's cached notifications (a dismiss on one profile appeared to clear it
  for all). Clear the cache on switch, mirroring logout.
- The live WS hook invalidated notification lists with refetchType:"none", so a
  newly pushed announcement only moved the unread badge — the home bar/inbox/bell
  didn't update until a manual reload. Refetch active lists when the arriving
  notification is an announcement (routine notifications keep the lighter
  mark-stale behavior to avoid disrupting a list being read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e2865c070b feat(notifications): pin announcements to the top of the inbox and bell
System announcements now sort above other notifications in the inbox "All" view
and the bell dropdown, so they're seen first. Sourced from the already-loaded
list (announcements are few and recent, so they're in the loaded window) via a
stable partition — no extra query, no pagination/cursor changes, and mark-on-view
and dismiss are unchanged. The dedicated "Announcements" tab is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code fd6797d907 fix(notifications): clear hidden dismiss X and add bell dropdown dismiss
The home announcement bar / notification strip dismiss X was covered on admin
accounts by the ServerActivity widget (fixed top-5 right-5, z-40), which floats
over the top-right of the page while a scan/stream is active. On the home page
for admins, shift the dismiss button left on large screens so it clears the
widget; the bar still spans full width.

Also add a per-row dismiss X to the bell dropdown (previously only the inbox and
home bars had one) — always visible since the bell is mobile-primary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code fede2a986f feat(notifications): fan out announcements per profile
Announcements were stored as one account-wide row (profile_id NULL) with a
shared read_at, so when any profile on a household account read or dismissed an
announcement it vanished for every profile on that account.

Fan out one row per profile instead — mirroring how content and request
notifications already work — with a profile-scoped dedup ref
(announcement-<id>-<profileID>) so the unique (user_id, type, dedup_ref) index
is satisfied across a single account's profiles. Each profile now reads and
dismisses independently. Accounts with no profiles fall back to a single
account-wide row.

- Add Store.ProfileIDsForUsers; replace DismissUnreadByTypeAndRef with
  DismissAnnouncementNotifications (matches both the per-profile refs and the
  legacy account-wide ref on delete).
- Update both fake stores and the announcement fan-out / delete tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code c2a33c7e35 fix(notifications): announcement audience list overlapping the Expires field
The shadcn/Radix ScrollArea collapses to near-zero layout height when given
only a max-height (its size-full viewport can't resolve against an indefinite
height), so the user/library selection list painted outside its box and the
Expires datetime field rendered on top of it. Replace both lists with a native
max-h-40 overflow-y-auto div, which reserves correct layout height and scrolls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0506336625 feat(notifications): in-app only — remove push config UI and frontend push code
Notifications are delivered entirely over the WebSocket (bell, inbox, live
toasts, home announcement bar), so the push subsystem — which only matters for
OS banners when the app is closed — isn't needed for the current product.

- Remove the admin Push Notifications settings page + its nav entry.
- Remove the "Push on this device" control and device list from user settings.
- Delete the orphaned frontend push code: usePushDevice, queries/push,
  lib/push, lib/pushSw, useServiceWorkerNavigation, public/sw.js, and the
  service-worker registration in main.tsx.
- Drop the now-unused pushKeys query keys and PushDeviceInfo/VapidKeyPair types.
- Clarify the Announcements admin copy (header, field help, audience, expiry).

The internal/push Go backend is left in place but dormant (no UI calls it,
no provider configured), so push can be re-enabled later if mobile ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 19a0e62f0d feat(web): home announcement bar and notification strip
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code af575d7470 docs: home notification bars design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 3b3e9ed7b9 fix(push): scope delivery claim to (profile,device); never prune on empty token
- Migration: add profile_id column to push_deliveries
- EnqueueDelivery: write d.ProfileID into the new profile_id column
- ClaimDue: SELECT pd.profile_id and JOIN user_devices on profile_id so a
  multi-profile device resolves to the exact token-bearing row, eliminating
  the tokenless-row collision and duplicate-row bugs
- Delivery struct: add ProfileID field to hold the scanned value
- worker handle(): guard against empty token before calling transport —
  skip with reason "empty token" so a data gap never triggers ResultDead
  → PruneTokenTx, which was silently wiping the real device token
- Tests: TestWorker_EmptyTokenSkippedNotDead (worker_test.go),
  TestEnqueuer_ProfileIDPropagated (enqueuer_test.go)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code d9ddfdb879 docs: notifications & push test checklist
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 3c0fd5f897 fix(web): navigate the SPA on notification click from the service worker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code d2c8be9162 chore(web): lint and format cleanup for push client files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 9a1b4ca3d7 fix(web): reflect configured status and clear-on-save for all push fields
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 4eb8c06023 feat(web): admin push provider config page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code dd54e90894 fix(web): fall back to absolute date for old device registrations
When a device registration date is older than 30 days, timeAgo() returns null,
silently dropping the registration date from the UI. Add a fallback to display
an absolute date using toLocaleDateString(), matching the pattern used in
Notifications.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code abb080c6ed feat(web): push on-this-device control and device list in settings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code f8b7cb0605 fix(web): guard all usePushDevice status writes by generation
enable() and disable() now each capture their own generation at the top
(after incrementing genRef) and gate every setStatus call on
`genRef.current === myGen`, matching the pattern already used by
refresh(). This closes the enable-clobbers-enable and disable-vs-enable
races the review flagged — only the most-recent in-flight call may write
a terminal status; superseded calls silently no-op.

Adds a test proving the guard: enable() stalls at the webpush-key fetch
while disable() runs to completion (gen=2); when the stale enable's
key call is then rejected, its catch set("off") is suppressed (gen=1 ≠
gen=2) and the final status remains "off" from disable, not clobbered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code ed1ab977b3 feat(web): usePushDevice subscription lifecycle hook
Implements the browser push subscription state machine (unsupported|blocked|off|on|pending) with enable(), disable(), and refresh() backed by serviceWorker.ready + pushManager. Uses a generation counter to prevent background refresh() from racing with user-initiated enable()/disable(). TDD: 5/5 tests green, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 2fe7c8683c feat(web): push query hooks and types
Add PushDeviceInfo and VapidKeyPair types, pushKeys factory, and
useWebPushPublicKey / usePushDevices / useTogglePushDevice /
usePushStatus / useGenerateVapidKeys / useSendTestPush hooks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 7d0154852d feat(web): web push service worker, registration, and helpers
Adds static sw.js (push/notificationclick/pushsubscriptionchange handlers),
tested pure logic in pushSw.ts (buildNotification/clickUrl), app-side helpers
in push.ts (urlBase64ToUint8Array/cacheVapidKey/pushSupported), and SW
registration in main.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e70bccceb7 feat(push): admin VAPID keygen and self-test endpoints
Add HandleGenerateVAPIDKeys (POST /admin/push/generate-vapid-keys) and
HandleSendTestPush (POST /admin/push/test) to PushHandler, with a new
pushNotifier interface and nil-tolerant third param on NewPushHandler.
Update both router.go call sites to pass deps.NotificationsService.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 615d35c131 docs: core push web client + admin config implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bbc865ce53 docs: core push web client + admin config design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 7649b30f77 fix(push): dedupe device list by device_id; document shared-device limitation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 088250f6ea chore(push): gofmt formatting 2026-06-11 09:45:49 +02:00
Claude Code ea049c3d1f chore(push): lint cleanup (errcheck, goconst, fcm credentials deprecation)
- transport_webpush.go: wrap defer resp.Body.Close() to silence errcheck
- transport_fcm.go: replace deprecated option.WithCredentialsJSON with
  google.CredentialsFromJSONWithType + option.WithCredentials; promotes
  golang.org/x/oauth2 from indirect to direct dependency
- worker_test.go: assert RunOnce errors instead of ignoring return values
  (errcheck); replace "apns"/"fcm" literals with TransportAPNs/TransportFCM
  and "sent"/"skipped"/"failed"/"dead" with Status* consts (goconst)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0b8b67f6c4 feat(push): wire presence, store, config, enqueuer, transports, worker, task
Wire the full push subsystem in cmd/silo/main.go: presence registry
(Redis when cfg.Redis.URL set, memory fallback), push store/config/
enqueuer/transports/worker, deps.PushStore/PushConfig/PresenceRegistry.
Add PresenceRegistry to api.Dependencies and thread it into
NewEventsHandler in router.go (replaces nil placeholder from Task 4).
Register push.NewPushDeliveryTask in taskMgr and pass pushStore to
NewNotificationsRetentionTask.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bb620a16ab feat(push): purge terminal deliveries in retention task
Extend NotificationsRetentionTask with an optional PushPurger dependency
that deletes terminal push_deliveries rows older than 7 days. Defines the
PushPurger interface in the tasks package; *push.Store satisfies it.
main.go call site updated to pass nil (real store wired in Task 15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code f9c5c13a1a fix(push): require profile middleware on token register/revoke routes
PUT /notifications/push/device and DELETE /notifications/push/device call
apimw.GetProfileID which is only populated by RequireProfile middleware, but
the routes were mounted without it, causing every request to 400 with
"profile required". Apply r.With(apimw.RequireProfile) per-route to those
two endpoints only; the three user-scoped routes (devices list, toggle,
webpush-key) are intentionally left without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 06d11fa062 feat(push): registration, device management, and status endpoints
Adds PushHandler with testable pushRegistry/pushConfigReader interfaces
and wires user push routes under /notifications/push and admin status
at /admin/push/status. All 16 handler tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code addeb47807 feat(push): fcm transport
Add FCMTransport backed by firebase.google.com/go/v4 using service-account
JSON credentials. Maps FCM errors to SendResult via fcmResult (Dead for
unregistered/invalid-argument, SoftFail+30s for quota/unavailable). Includes
unit tests for nil-success path and unconfigured soft-fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e7d7fd87d6 feat(push): apns transport
Token-based (.p8) APNs transport with pure apnsResult helper; unit-tested reason→result mapping and unconfigured soft-fail path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 84f027aa07 feat(push): web push transport
Implements WebPushTransport via github.com/SherClockHolmes/webpush-go v1.4.0.
Handles ResultDead on malformed subscriptions and 404/410 responses, soft-fail
with Retry-After on 429, and deduplication via Topic header (base-36 notification ID).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code afa6025a2a test(push): cover parked-transport, unconfigured-skip, intermediate backoff
Add doc comment to txOutcomes.run explaining atomic-batch semantics and
duplicate-send risk. Add three new tests: SkipsUnconfiguredTransport_NoCalls
(asserts Send never called), ParkedTransportFails (two deliveries in one
RunOnce, only one Send, both failed at now+2m), and IntermediateBackoff
(attempts 1→5m, attempts 2→30m via backoff schedule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 6a5f61dd39 feat(push): delivery worker, store-backed queue, scheduled task
Implements the push delivery outcome state machine (Worker/RunOnce/handle),
store-backed queue adapter (storeQueue/txOutcomes over ClaimDue tx), and
PushDeliveryTask (15 s interval, drains up to 20 batches). TDD: 5 unit tests
cover sent, skip-present-user, skip-unconfigured, soft-fail backoff→dead,
and hard-fail dead-token paths — all passing, no DB required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0b96771964 feat(push): encrypted provider config and status
Register push provider credential keys in SensitiveSettingKeys so they
are encrypted at rest via EncryptedSettingsRepo. Add internal/push/Config
that reads per-transport credentials on demand and exposes a Status report
of configured booleans without leaking secrets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 497fd6ee71 feat(push): enqueuer and notifications hook
Adds PushEnqueuer interface as a seam in the notifications package (no
import-cycle: push imports notifications, not the other way around), and
implements Enqueuer in internal/push that fans out per-device delivery
rows with a configurable presence-grace not_before. Service.Create() calls
the enqueuer after a successful insert+publish; nil-safe so existing tests
are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bde9d563ab feat(push): pgx store for devices and deliveries 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code f25cbb27aa feat(push): domain types and transport interface 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code bf9746ce32 feat(presence): track live connections in events websocket
Wire the presence Registry into EventsHandler so each WebSocket
connection registers/releases the user's live presence, and the
ping-loop refreshes the TTL for Redis-backed registries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 1fa00b784f feat(presence): redis-overlay registry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 4d3f366dd2 feat(presence): in-process connection registry
Adds MemoryRegistry, a thread-safe refcount-based Registry that tracks
live realtime connections per user for suppressing push notifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code d5791ed541 feat(push): user_devices push columns and push_deliveries table 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 0a52b47e4e docs: core push notifications server implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 418b7347ca docs: core push notifications design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 75b4e1c72f chore(web): prettier formatting for notifications files 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 6e817706f1 feat(web): admin announcements management page
Adds AdminAnnouncements page with table (title, audience summary, dates,
delete action), create dialog with audience mode selection (Everyone /
Specific users / Libraries), optional expiry, and validation gating;
wires Megaphone nav item in AdminSidebar and /admin/announcements route
in App.tsx. 6 TDD tests covering audience summaries, submit-disabled
states, payload shapes, delete confirm, and empty state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 404f782ee2 feat(web): optimistic notification preference toggles 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ab7c081ecc feat(web): notification preference toggles in settings
Adds NotificationsSettings page with per-category toggle rows wired to
useNotificationPreferences / useSetNotificationPreferences. Admin alerts
row is hidden for non-admin users. Nav entry (Bell icon) placed between
Appearance and Library & Data sections. Route registered in App.tsx.

Tests: NotificationsSettings.test.tsx (jsdom / createRoot style matching
sibling settings pages) — 6 cases covering admin/non-admin row counts,
content toggle mutation payload, digest default-off state, loading
disabled state, and footer note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 01f828a11a fix(web): inbox relative-time copy and per-tab mark-on-view
- Parameterize timeAgo() with optional prefix (defaults to "Added")
  - Call with empty string to omit prefix: timeAgo(date, "")
  - Compose relative-time strings to avoid leading space when prefix=""
  - ItemCard callers use default; Notifications uses "" for cleaner UI

- Reset mark-on-view flag on category tab change
  - Each tab's first page now marks its unread rows independently
  - prevTabRef tracks activeTab; reset markedRef when it changes
  - Test: switch tab with unread item → markRead.mutate called again

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a79eb85ca0 feat(web): notifications inbox page with TDD
Adds /notifications route and Notifications page with category tabs (All,
Requests, Content, Announcements, System, Admin-for-admins), mark-all-read
header button, mark-on-view (first-page unread ids, ref-guarded), per-row
dismiss button, Load-more pagination, and 7 passing vitest tests (red→green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 5711771c9e test(web): real zero-count assertion for sidebar badge
Replace module-level mock with hoisted mutable-state pattern to properly
test the zero-count branch. The test now sets mockUnreadCount.data = 0
and verifies badge absence (checking for the absence of "rounded-full px-0.5"
class), ensuring the condition {unreadCount > 0 && <badge />} is tested
structurally rather than just semantically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code cdbfcbe9af feat(web): notifications sidebar item with unread badge
Add a Notifications nav item after History in the Your Stuff section of
AppSidebar, with a floating badge showing the unread count from
useUnreadCount. Badge is capped at 99+ and hidden when count is zero.
The bell icon is wrapped in a relative span so the badge stays visible
in collapsed sidebar mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ea92eb6c69 fix(web): notifications list staleTime; document bell flag constraint
Add staleTime configuration to useNotificationsList to prevent unnecessary refetches when the popover is opened multiple times. Update NotificationBell module comment to clarify that the bell must be a single instance — a second instance would conflict over the module flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code f7866a78d5 feat(web): notification bell with live badge in mobile header
Adds NotificationBell popover component with unread badge, per-item list,
mark-all-read, and dropdown-open toast suppression wired via isNotificationDropdownOpen();
mounts the bell and useNotificationsLive() once in Layout's mobile header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 59cfb01e71 feat(web): live notifications wiring (toasts, unread cache)
Adds useNotificationsLive hook (TDD): subscribes to the notifications WS
channel, toasts on notification.created for the active profile (with child
profile category filtering), bumps the unread-count cache optimistically,
and seeds the count from the channel snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 88f0228968 feat(web): notification query hooks
Add notificationKeys factory to keys.ts and create notifications.ts with
all nine TanStack Query hooks: infinite list, unread count (raw shape for
live cache writes), mark-read, dismiss, preferences CRUD, and admin
announcements CRUD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 4be3f498cf feat(web): notification API types and event channel 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ac4a44c62c docs: core notifications web implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a724ac9c55 chore: gofmt notifications handler tests 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code abca92eec5 docs: record implementation-time findings in spec future work
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 30f59accd7 chore(notifications): lint cleanup in new files
- Fix gofmt issues in service_test.go and notifications_retention_test.go
- Remove useless type assertion in matcher_content.go (m.content already is ContentResolver)
- Replace "catalog.item.changed" string literal with TypeCatalogItemChanged constant
- Extract "metadata_updated" to const metadataUpdated in matcher_content.go
- Use TypeJobFailed constant in matcher_admin.go
- Extract "scan.failed" and "/admin/tasks" to consts eventScanFailed and adminTasksLink
- All tests pass; no golangci-lint errors in new matcher files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code c24df8b3bd feat(notifications): wire store, service, materializer, tasks
Constructs notificationsStore/notificationsSvc/notificationsMaterializer
immediately after the deps literal in main.go (where pool and eventsHub
are both in scope), starts the materializer, and sets
deps.NotificationsService so the router registers all notifications
routes.

Calls SetEventsHub on the API-facing requestSvc (router.go) and the
task-manager reconcile service (main.go) so request lifecycle events
reach the materializer.

Registers NewNotificationsRetentionTask and NewNotificationsDigestTask
with the task manager alongside the other system tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1c823aa507 fix(notifications): digest fires daily at fixed time
Changes digest trigger from interval-based (24h) to daily pattern firing at 08:00.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code c7079000a8 feat(notifications): digest and retention scheduled tasks
Add RunDailyDigest service method, DigestSubscribers/AddedItemCountForUser
store methods, and NotificationsRetentionTask/NotificationsDigestTask with
full TDD coverage including dedup, zero-count skip, and per-user error
continuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 9ce999a61d feat(notifications): system notification on password change
Add CreateSystem helper to notifications.Service that swallows errors
so security notices never break the calling flow. Wire it into
AdminHandler.HandleUpdateUser so a system.password_changed notification
is sent to the target user whenever a password is included in an admin
update. Add service-level and handler-level tests covering both the
happy path and the store-failure/no-password cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a638494540 feat(notifications): expose notifications and requests channels over WS with unread snapshot
Grants ChannelNotifications and ChannelRequests to all roles, adds
snapshotForChannel cases (unread_count via notifications service, {} for
requests), and wires NotificationsService into EventsHandler nil-tolerantly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code b5b3cccc18 fix(notifications): fail closed on child-profile resolution; cursor test
- childSafe() now returns true on ForUser or GetProfile errors, treating
  unresolvable profiles as children to prevent restricted categories from
  leaking on errors. Updated doc comment to clarify fail-closed behavior.
- Added TestNotificationsList_ResolverErrorFailsClosed to verify resolver
  errors trigger ChildSafe=true.
- Added TestNotificationsList_NextCursorSet to verify next_cursor is set
  correctly when limit=2 with exactly 2 items (next_cursor == ID of last item).
- Added cross-reference comments on limit clamps in both handlers and store
  files to ensure they stay in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 2e9435d69e fix(notifications): resolve child profiles for category filtering
Add a profileResolver interface and childSafe helper to NotificationsHandler
so HandleList and HandleUnreadCount pass ChildSafe=true when the active profile
has IsChild=true. Provider is nil-tolerant (ChildSafe=false when absent).
Wire deps.UserStoreProvider in both router construction sites and add
TestNotificationsList_ChildProfileFiltered (plus adult/nil-resolver variants).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code f4fe1f7d94 feat(notifications): REST API for inbox, preferences, announcements
Adds service pass-throughs (List, UnreadCount, MarkRead, MarkAllRead,
Dismiss, GetPreferences, SetPreferences, ListAnnouncements), a
NotificationsHandler with cursor-paginated list, unread count, mark
read/all, dismiss, get/put preferences, and admin announcement CRUD,
wired into the chi router under /notifications (user, profile-scoped)
and /admin/announcements (RequireAdmin). TDD: 15 handler tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1094ecb523 test(notifications): scan.failed coverage and admin alert assertions
Add TestAdminMatcher_ScanFailedNotifiesAdmins to verify scan.failed events
trigger admin notifications with proper event type, body content, link, and
dedup ref. Enhance TestAdminMatcher_JobFailedNotifiesAdmins with assertions on
Body containing job_type and job id, Link format, and DedupRef prefix. Add
clarifying comment on ChannelTasks entry explaining why it exists despite no
current task.failed event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 4f80010de5 feat(notifications): admin failure alerts with hourly throttle
Replaces the matchAdmin stub with a real implementation that fans job.failed
and scan.failed events out to all admin users, throttled to one alert per
(event, source-kind) per clock-hour via a day+hour dedup_ref bucket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 229379e0a6 fix(notifications): day-bucket dedup, payload library authority, interest indexes
- Change hour-bucket dedup format to day-bucket (20060102) so refresh-driven
  repeats within a 48h window collapse while next-week episodes still notify.
- Use payload.LibraryID as authoritative library when non-zero; resolver's
  libraryID is now a fallback for events that omit the field.
- Pass raw seriesID as separate $4 parameter for the in-progress leg guard
  so a standalone item can never join episode rows on its own content id.
- New migration (20260610172401_notifications_interest_indexes) adds
  user_watchlist_media_item_idx and user_favorites_media_item_idx to support
  the InterestedProfiles access pattern efficiently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 954d26360f fix(notifications): gate content notifications on item recency
Extend ContentResolver.ItemContext to return the catalog row's created_at
and suppress content.added notifications when time.Since(createdAt) exceeds
the new 48-hour newItemWindow constant.  This prevents periodic library
refresh jobs (library_refresh.go, item_refresh.go) from re-notifying users
about years-old watchlist items on every metadata_updated event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 09f202f9f7 feat(notifications): content matcher with interest resolution and burst dedup
Implements ContentResolver interface (ItemContext + InterestedProfiles),
matchContent handler keyed on catalog.item.changed/metadata_updated with
hour-bucketed dedup_ref for series burst collapse, and ContentResolverRepo
backed by pgxpool joining media_items/episodes/user_watchlist/user_favorites/
user_watch_progress. No item_added change value exists in codebase; plan
assumption corrected to metadata_updated. In-progress leg retained using
user_watch_progress(updated_at). TDD: 4 new tests, red phase confirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 833ce25178 fix(notifications): scope notifications.send contract to plugins channel
Only envelopes on the ChannelPlugins channel are now processed by the
notifications.send matcher. Core code already calls Service.Create directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a047519230 feat(notifications): notifications.send plugin contract
Implements matchSend in the Materializer so plugins can create
notifications by publishing a notifications.send event on ChannelPlugins.
Accepts bare and prefixed (plugin.<id>.*) event names; drops malformed
payloads and rejects announcements from plugin callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 7c65c7b4dc fix(notifications): self-event guard, race-safe Stop, complete event mirror
- Add self-event guard in Materializer.handle() to prevent reacting to own notifications output
- Make Materializer.Stop() idempotent/race-safe using sync.Once
- Complete RequestEventData mirror by adding Quality field for payload completeness
- Add Title assertion in TestRequestMatcher_ApprovedCreatesNotification for coverage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 98ed20444a feat(notifications): materializer with isolated matchers; request matcher
Adds Materializer (hub consumer with panic-isolated per-matcher dispatch) and
matchRequest (request.approved/declined/completed/failed → notifications rows).
Stubs matchSend, matchContent, matchAdmin for later tasks. TDD: tests written
first and confirmed failing before implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code e59343968d fix(requests): publish lifecycle events from aggregate request state
- Reconcile per-target loop: publish request.completed/request.failed
  based on updatedReq.Status/Outcome (aggregate), not the individual
  target's newStatus. Partial failure (one target fails while a sibling
  is still downloading) no longer fires a spurious request.failed event.
- Approve soft-fail: extract publishApprovalOutcome helper that emits
  request.failed when Outcome==OutcomeFailed, else request.approved.
  Approve and CreateRequest auto-approve both use it, so a submission
  failure (no backend configured, no targets returned) publishes
  request.failed instead of request.approved.
- Update TestApprove_PublishesRequestApproved to use a working router
  so it actually exercises the request.approved branch.
- Add TestReconcile_PartialTargetFailureDoesNotPublishFailed,
  TestApprove_SoftFailPublishesRequestFailed, and
  TestCreateRequest_AutoApprovePublishesApproved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 0add71fbd8 feat(requests): publish lifecycle events on ChannelRequests
Adds RequestEventPayload, SetEventsHub, and publishRequestEvent to the
requests service. Publishes request.submitted, request.approved,
request.declined, request.cancelled, request.completed, and
request.failed at each real transition site. Nil hub is a no-op.
Tests cover the approved publish path and the nil-hub no-panic contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 8d5e0fec85 fix(notifications): exact-match announcement dismissal, fan-out ordering, error wrapping
Replace prefix LIKE dismissal with exact dedup_ref match, resolve audience
before InsertAnnouncement to avoid orphaned rows on error, add proper error
wrapping throughout, and make fakeStore assign incrementing IDs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 423e2c02ae feat(notifications): announcement publish with audience fan-out
Add AllEnabledUserIDs to Store interface and Repository, implement
PublishAnnouncement with audience resolution (all/user_ids/library_ids),
deduplication, and expiry propagation; add DeleteAnnouncement that
dismisses unread notification rows on removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 58fbc8f23c fix(notifications): WarnContext + digest opt-in tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 45995f659c feat(notifications): create service with prefs, dedup, hub publish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1756f54e21 fix(notifications): escape LIKE prefix, guard empty MarkRead, document store contracts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 5150fec3e3 feat(notifications): pgx store
Implements Store interface and Repository backed by pgxpool for the
notifications inbox: insert with dedup, list/unread-count with cursor
pagination, mark-read, dismiss, preferences upsert, announcements CRUD,
bulk-dismiss by type-ref, purge-old, and admin/library user-ID helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code bf880b292b feat(notifications): domain types 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code db22615874 feat(events): add notifications and requests channels 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code cfbd95e31c feat(notifications): add notifications, preferences, announcements tables 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 70e8190348 docs: core notifications server implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 9626fd8f13 docs: core notifications system design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
0eb87fa0f5 fix(player): flip ASS subtitle delay sign to match VTT semantics (#130)
JASSUB renders the ASS event matching video.currentTime + timeOffset,
so an event at source time S appears at video time S - timeOffset.
Adding the user delay to the offset therefore showed ASS subtitles
EARLIER on positive delay, while the VTT path (and the subtitle menu's
"later" label) shifts cues later. At +2000ms an ASS track diverged from
an SRT track by a full 4 seconds.

Subtract the delay instead, correct the comment to state the actual
JASSUB render equation, and add tests covering the constructed
timeOffset for positive/negative delays plus the live-instance update
path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:28:46 -04:00
fadd8ff456 feat(player): native PGS subtitle rendering via libpgs (#129)
* feat(playback): add IsPGS helper and sup streaming extract path

PGS (Blu-ray bitmap) subtitle tracks can be copied losslessly into a .sup
elementary stream for client-side rendering, so they no longer have to be
burned in. streamExtractOutput maps PGS to (copy, sup), and the seek/-t
windowing now skips PGS like ASS: both formats are fetched once and
consumed whole by their client-side renderers.

This also fixes a pre-existing truncation bug: the -t duration cap was
applied unconditionally, cutting embedded ASS extracts off at the default
600s window even though the ASS client fetches the full track.

Extract the ffmpeg argument construction into streamExtractArgs for
testability, following the buildFFmpegArgs pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): expose PGS subtitle tracks as .sup stream URLs

PGS tracks were filtered out of /playback/start subtitle_urls entirely,
so the web player showed no subtitles for PGS-only files (#34). Include
them with a .sup URL extension; DVD/DVB bitmap tracks stay hidden since
they still have no non-burn-in delivery path.

HandleSubtitle streams the full PGS track as application/octet-stream.
The seek/duration window is forced to zero for sup: subtitleSeekPosition
falls back to the session's last reported position even without a
?position= query, which would otherwise start the extract mid-file. The
proxy-node subtitle handler gets the same sup branch, streaming ffmpeg
output directly instead of buffering like its text paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(player): consolidate subtitle codec helpers into subtitleCodecs.ts

Rename assSubtitles.ts to subtitleCodecs.ts — the module already labeled
every codec, not just ASS — and add isPGSCodec/isBitmapCodec. Replace the
duplicated BITMAP_CODECS set in SubtitleTranslateModal with the shared
helper so codec lists live in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(player): native PGS subtitle rendering via libpgs

Render PGS subtitle tracks client-side instead of leaving them
unavailable (#34). usePGSSubtitles mirrors the JASSUB hook: when a PGS
track is active it lazy-loads libpgs, which fetches the .sup stream in a
worker, decodes display sets progressively as bytes arrive, and draws
them onto a canvas positioned over the video.

The renderer looks up the display set at currentTime + timeOffset, so
the HLS stream origin adds and the user-facing delay subtracts — a
positive delay shows subtitles later, matching VTT semantics. Offset
changes apply through the timeOffset setter without recreating the
renderer; track switches, PiP detach, and unmount dispose it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(player): prefer text over bitmap tracks in subtitle auto-select

With PGS tracks now listed, an earlier PGS track would win auto-select
over a later same-language SRT/ASS track. Deprioritize bitmap codecs
within the same source tier — text is lighter to render and styleable —
while a PGS track still wins when it is the only language match, and
forced-PGS auto-select now works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:18:29 -04:00
a17529fd6f feat(config): live admin settings + truthful restart-required banner (#128)
* feat(nodeconfig): harden config watcher for integrated-mode use

- RequestReload(): non-blocking, coalescing reload nudge that runs on the
  poll goroutine, so concurrent requests can never swap a stale snapshot
  over a newer one (unlike ForceReload from request handlers)
- Skip OnChange callbacks when the reloaded config is deep-equal to the
  previous one, so the 60s poll doesn't fire rebuild/log callbacks on
  no-op reloads
- Add RedisURL to BootstrapOverrides; previously a reload clobbered an
  env-provided Redis URL in the live config
- Split reload into fetchSettings/applySettings and add unit tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(config): hot-reload config watcher in integrated mode

Start nodeconfig.Watcher in integrated/api mode (previously only proxy/
transcode worker modes hot-reloaded). Expose the live config to the API
and jellycompat routers via func-typed LiveConfig/OnConfigChange fields
with nil fallbacks to the startup snapshot, and wire the admin settings
update hook to RequestReload so same-process changes apply immediately
even without Redis.

No consumer reads the live config yet — conversions land separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(admin): truthful restart-required banner for settings saves

The settings UI showed 'restart required' after every save regardless of
the key. The backend now classifies each key via a central registry
(internal/config/restart_keys.go) and PUT /admin/settings/{key} reports
restart_required per key; useSettingsForm only raises the banner when a
saved key actually needs a restart (and keeps it raised until restart).

The registry is conservative: every currently startup-frozen key is
marked restart-required; subsequent hot-reload conversions shrink it.
Settings read live from the settings repo (branding, overlays, markers,
download.*, ...) default to no-restart. DownloadSettings/OverlaySettings
drop their hardcoded restartRequired={false} special-casing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(logging): hot-reload server.log_level and server.log_quiet

Share one slog.LevelVar across the handler chain and make
logfilter.Handler's quiet-prefix list an atomic pointer shared with
WithAttrs/WithGroup clones (New previously returned the inner handler
unwrapped when the quiet list was empty, leaving nothing to update).
The integrated-mode config watcher now applies both settings live;
their keys leave the restart-required registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(auth): hot-reload access/refresh token expiries

JWTService stores expiries as atomics with a SetExpiries hook; all three
instances (main API, ABS compat, jellycompat) re-apply them on config
reload. Applies to newly issued tokens; outstanding tokens keep their
original expiry. The JWT secret stays fixed for the process lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): read transcode config live at session start

The playback and stream handlers pull ffmpeg path / hwaccel / transcode
dir from the live config when starting a transcode or extracting
subtitles, instead of values frozen at router construction. Each session
snapshots the config once so its output dir and binary stay consistent.

Also fixes a real bug: playback.hw_device was parsed into the config but
never wired into the integrated-mode handler, so local transcodes always
ran with an empty HWDevice while transcode nodes honored it.

playback.transcode_dir leaves the restart-required registry (the handler
is its only consumer); ffmpeg_path/hw_accel stay restart-required until
scanner/chapterthumbs/audiobook consumers convert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(jellycompat): read compat identity settings live per request

System/Auth handlers take a config provider instead of the startup
snapshot, so jellyfin_compat.public_url, .server_name, and
.emulated_server_version apply without restart. server_id stays
restart-required (generate-once, baked into the resource mapper), as do
the session-store TTLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scanner,metadata,mdblist): hot-reload worker pools and API key

scanner.workers, matcher.workers/batch_size, metadata.cache_images, and
mdblist.api_key convert to atomic fields with setters wired to the
config watcher. Worker counts apply on the next scan/match cycle (the
loops read them per cycle); the MDBList key applies to the next request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ai): hot-reload AI connection, models, toggles, and quotas

The shared llm.Client holds its config behind an atomic pointer
(UpdateConfig; each request snapshots once), and the subtitle/metadata
AI services gain UpdateConfig plus setters on the translator (batching)
and Whisper transcriber (ffmpeg path, chunk seconds). The router derives
their configs from shared helpers used both at construction and in
OnConfigChange callbacks, re-evaluating the chat-only-gateway transcribe
guard on each reload and warning only when it newly fires.

Everything on the AI Services page now applies without restart except
ai.max_concurrent_jobs (fixed-capacity dispatch semaphore).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): wire transcode_enabled; remove dead playback/scanner knobs

playback.transcode_enabled was parsed into the config but the resolver
always received a hardcoded true — the admin toggle did nothing. It now
reads the live config per playback start, so disabling transcodes
applies without restart.

Remove settings that were wired to nothing so 'save + restart' stops
pretending: playback.allow_hevc_encoding (resolver field never
assigned), playback.transcode_ahead_segments and
playback.segment_duration (parsed, never consumed — segment duration is
per-session from the client), scanner.file_removal_grace (DeleteMissing
is never called). UI fields removed and the config struct fields pruned
so they don't resurrect; YAML import still tolerates the legacy keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:25:07 -04:00
222 changed files with 21503 additions and 8251 deletions
+119 -20
View File
@@ -69,7 +69,9 @@ 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"
@@ -120,7 +122,7 @@ func resolvePluginCacheDir() string {
return filepath.Join(os.TempDir(), "silo-plugins")
}
func buildBaseHandler(format string, level slog.Level) slog.Handler {
func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
opts := &slog.HandlerOptions{Level: level}
if strings.EqualFold(format, "json") {
return slog.NewJSONHandler(os.Stderr, opts)
@@ -128,6 +130,19 @@ func buildBaseHandler(format string, level slog.Level) 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 {
@@ -144,8 +159,7 @@ func configureOperationalLogging(
settingsRepo catalog.SettingsStore,
redisCfg config.RedisConfig,
logStreamHub *logstream.Hub,
baseHandler slog.Handler,
logQuiet string,
filteredHandler slog.Handler,
nodeID string,
) (opslog.Writer, *opslog.Repo, *partman.Manager) {
if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil {
@@ -182,7 +196,6 @@ 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
@@ -457,20 +470,14 @@ func main() {
log.Fatalf("config validation: %v", err)
}
// 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)))
// 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))
mode := cfg.Server.Mode
maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode)
@@ -491,8 +498,19 @@ 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, baseHandler, cfg.Server.LogQuiet, nodeID)
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, quietFilter, nodeID)
defer func() {
if err := eventBus.Close(); err != nil {
slog.Warn("event bus close error", "error", err)
@@ -512,6 +530,7 @@ 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 {
@@ -553,6 +572,29 @@ 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"
@@ -568,6 +610,8 @@ func main() {
deps := api.Dependencies{
Config: cfg,
LiveConfig: configWatcher.Config,
OnConfigChange: configWatcher.OnChange,
BootstrapSensitiveConfigured: bootstrapSensitiveConfigured,
BootstrapSensitiveValues: bootstrapSensitiveValues,
AppContext: appCtx,
@@ -588,7 +632,37 @@ 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 {
@@ -707,6 +781,9 @@ 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")
@@ -1044,6 +1121,9 @@ 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)
}
@@ -1061,6 +1141,10 @@ 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)
@@ -1495,6 +1579,10 @@ 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)
})
}
}
@@ -1554,6 +1642,7 @@ func main() {
),
)
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
requestReconcileSvc.SetEventsHub(eventsHub)
api.AttachRequestRouter(requestReconcileSvc, pluginService)
if userStoreProvider != nil {
reconcileResolver := access.NewResolver(
@@ -1612,6 +1701,9 @@ 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()
@@ -1631,6 +1723,9 @@ 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,
@@ -1895,6 +1990,7 @@ 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,
@@ -1954,7 +2050,7 @@ func main() {
// Construct auth service for jellycompat login.
userRepo := auth.NewUserRepository(deps.DB)
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
compatDeps.UserLoader = userRepo
compatDeps.APIKeyUserLoader = userRepo
compatDeps.ScanQueue = deps.LibraryScanQueue
sessionRepo := auth.NewSessionRepository(deps.DB)
jwtService := auth.NewJWTService(
@@ -1962,6 +2058,9 @@ 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)
@@ -0,0 +1,100 @@
# 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
@@ -0,0 +1,809 @@
# 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
@@ -0,0 +1,838 @@
# 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
@@ -0,0 +1,188 @@
# 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.
@@ -0,0 +1,173 @@
# 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.
@@ -0,0 +1,120 @@
# 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.
@@ -1,170 +0,0 @@
# 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.
@@ -0,0 +1,65 @@
# 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.
+11 -5
View File
@@ -20,6 +20,7 @@ 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
@@ -30,20 +31,25 @@ 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
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
golang.org/x/oauth2 v0.36.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa
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
@@ -56,6 +62,7 @@ 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
@@ -69,7 +76,6 @@ 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
)
@@ -98,10 +104,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.20.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.37.0
golang.org/x/time v0.14.0
google.golang.org/grpc v1.80.0
golang.org/x/time v0.15.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
)
+97 -14
View File
@@ -1,11 +1,17 @@
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=
@@ -45,8 +51,9 @@ 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=
@@ -69,10 +76,15 @@ 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=
@@ -153,8 +165,9 @@ 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=
@@ -185,10 +198,13 @@ 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=
@@ -215,6 +231,7 @@ 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=
@@ -233,10 +250,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.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/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/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=
@@ -249,44 +266,110 @@ 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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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/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.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
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-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/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/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-5
View File
@@ -5,9 +5,4 @@ import "errors"
var (
ErrProfileNotFound = errors.New("profile not found")
ErrProfileUnverified = errors.New("profile unverified")
// ErrUserDisabled is returned when the viewer's account is disabled.
// Scope resolution re-loads the user on every request, so disabling an
// account denies content access on the next request for every surface
// that resolves viewer scope (native API and Jellyfin compat alike).
ErrUserDisabled = errors.New("user account disabled")
)
-14
View File
@@ -79,17 +79,3 @@ func MinQuality(a, b string) string {
return b
}
}
// MaxQuality returns the more permissive quality ceiling. An empty string
// means unrestricted and beats any concrete ceiling.
func MaxQuality(a, b string) string {
a = NormalizePlaybackQuality(a)
b = NormalizePlaybackQuality(b)
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
return ""
}
if CompareQuality(a, b) >= 0 {
return a
}
return b
}
-19
View File
@@ -54,22 +54,3 @@ func TestParsePlaybackQualityPreset(t *testing.T) {
t.Fatal("expected invalid preset to return ok=false")
}
}
func TestMaxQuality(t *testing.T) {
cases := []struct {
a, b, want string
}{
{"", "", ""},
{"", "1080p", ""}, // unrestricted beats any ceiling
{"2160p", "", ""},
{"1080p", "2160p", "2160p"},
{"2160p", "1080p", "2160p"},
{"1080p", "1080p", "1080p"},
{"720p", "480p", "1080p"}, // presets normalize: 720p/480p both -> 1080p (standard)
}
for _, tc := range cases {
if got := MaxQuality(tc.a, tc.b); got != tc.want {
t.Errorf("MaxQuality(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
}
}
}
-3
View File
@@ -46,9 +46,6 @@ 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,
+7 -20
View File
@@ -256,7 +256,7 @@ func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int)
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, 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, Enabled: true, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, 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, Enabled: true, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, 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, Enabled: true, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, 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, Enabled: true, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, 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, Enabled: true, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, 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,22 +388,9 @@ 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, Enabled: true, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
settings: map[string]string{"disabled_library_ids": "[7]"},
}},
+25 -8
View File
@@ -16,6 +16,7 @@ import (
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
)
@@ -56,8 +57,11 @@ 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 Config
cfg atomic.Pointer[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.
@@ -67,11 +71,23 @@ type Client struct {
// NewClient builds a client from the endpoint config.
func NewClient(cfg Config) *Client {
return &Client{
cfg: cfg,
c := &Client{
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.
@@ -107,8 +123,9 @@ 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: c.cfg.ChatModel,
Model: cfg.ChatModel,
Messages: messages,
Temperature: 0.2,
}
@@ -121,7 +138,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool)
return "", fmt.Errorf("marshal chat request: %w", err)
}
url := endpointURL(c.cfg.BaseURL, "chat/completions")
url := endpointURL(cfg.BaseURL, "chat/completions")
var content string
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
@@ -131,8 +148,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 c.cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
return httpReq, nil
},
@@ -147,7 +164,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", c.cfg.ChatModel, "response_bytes", len(respBody))
"model", cfg.ChatModel, "response_bytes", len(respBody))
return fmt.Errorf("chat API returned no choices")
}
content = parsed.Choices[0].Message.Content
+9 -8
View File
@@ -142,12 +142,13 @@ 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) {
if !c.cfg.ASRConfigured() {
cfg := c.Config()
if !cfg.ASRConfigured() {
return nil, fmt.Errorf("transcription endpoint is not configured")
}
if IsChatOnlyGateway(c.cfg.asrBaseURL()) {
if IsChatOnlyGateway(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", c.cfg.asrBaseURL())
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", cfg.asrBaseURL())
}
if len(req.Audio) == 0 {
return nil, fmt.Errorf("no audio data to transcribe")
@@ -160,7 +161,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := endpointURL(c.cfg.asrBaseURL(), "audio/transcriptions")
url := endpointURL(cfg.asrBaseURL(), "audio/transcriptions")
var result *Transcription
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
@@ -175,7 +176,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
return nil, fmt.Errorf("write multipart audio: %w", err)
}
fields := [][2]string{
{"model", c.cfg.ASRModel},
{"model", cfg.ASRModel},
{"response_format", "verbose_json"},
{"temperature", "0"},
// Word timings let cue building split paragraph-length
@@ -187,7 +188,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
if req.Language != "" {
fields = append(fields, [2]string{"language", req.Language})
}
if !hostMatchesAny(c.cfg.asrBaseURL(), strictHostedASRHosts) {
if !hostMatchesAny(cfg.asrBaseURL(), strictHostedASRHosts) {
fields = append(fields, [2]string{"vad_filter", "true"})
}
for _, f := range fields {
@@ -204,7 +205,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 := c.cfg.asrAPIKey(); key != "" {
if key := cfg.asrAPIKey(); key != "" {
httpReq.Header.Set("Authorization", "Bearer "+key)
}
return httpReq, nil
@@ -219,7 +220,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", c.cfg.ASRModel)}
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", cfg.ASRModel)}
}
out := &Transcription{Language: parsed.Language}
for _, s := range *parsed.Segments {
+223 -107
View File
@@ -1,6 +1,7 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -26,6 +27,8 @@ 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"
@@ -102,17 +105,11 @@ 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,
// 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),
userRepo: userRepo,
pool: pool,
storeProv: storeProv,
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
}
}
@@ -120,53 +117,115 @@ 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"`
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"`
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
}
// 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"`
Enabled *bool `json:"enabled,omitempty"`
// GroupIDs replaces the user's group memberships. Omitted = unchanged.
GroupIDs *[]int `json:"group_ids,omitempty"`
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"`
}
// 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.
// adminUserResponse represents a user in admin JSON responses.
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"`
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"`
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"`
}
type adminPlaybackHistoryRow struct {
@@ -212,21 +271,15 @@ func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
return ""
}
// toAdminUserResponse converts a User model (with its group memberships
// loaded into u.Groups at load time) to an admin API response.
// toAdminUserResponse converts a User model 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: auth.RoleForUser(u),
Role: u.Role,
Permissions: append([]string{}, u.Permissions...),
Enabled: u.Enabled,
Groups: refs,
LibraryIDs: append([]int(nil), u.LibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
MaxStreams: u.MaxStreams,
@@ -289,13 +342,10 @@ 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)
@@ -345,17 +395,44 @@ 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 == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
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())
return
}
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
User: models.CreateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
GroupIDs: req.GroupIDs,
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,
},
DefaultProfile: auth.DefaultProfileOptions{
Enabled: req.CreateDefaultProfile,
@@ -363,10 +440,6 @@ 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
}
@@ -390,12 +463,43 @@ 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,
Enabled: req.Enabled,
GroupIDs: req.GroupIDs,
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,
}
var currentUser *models.User
@@ -417,14 +521,6 @@ 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
}
@@ -435,6 +531,15 @@ 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")
@@ -455,10 +560,6 @@ 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
}
@@ -702,8 +803,10 @@ 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.GroupIDs != nil
input.Permissions != nil ||
input.MaxPlaybackQuality != nil
}
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
@@ -713,28 +816,22 @@ 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
}
// 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) {
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
return true
}
if input.MaxPlaybackQuality != nil &&
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
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
@@ -935,7 +1032,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
}
writeJSON(w, http.StatusAccepted, adminJobToResponseForViewer(r, job, nil, isAdminRequest(r, h.userRepo)))
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
}
// UpdateItemMetadataRequest contains the fields that can be updated via
@@ -1131,6 +1228,9 @@ 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 {
@@ -1994,6 +2094,21 @@ 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 {
@@ -2044,9 +2159,10 @@ 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})
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
}
-29
View File
@@ -1,29 +0,0 @@
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,6 +27,7 @@ func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, reader)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "admin",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
-379
View File
@@ -1,379 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// GroupStore defines the group repository operations the AdminGroupsHandler
// needs, so tests can substitute a fake.
type GroupStore interface {
List(ctx context.Context) ([]auth.GroupWithMemberCount, error)
GetByID(ctx context.Context, id int) (*models.Group, error)
MemberCount(ctx context.Context, groupID int) (int, error)
Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error)
Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error)
Delete(ctx context.Context, id int) error
ListMembers(ctx context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error)
AddMember(ctx context.Context, groupID, userID int) error
RemoveMember(ctx context.Context, groupID, userID int) error
}
// AdminGroupsHandler handles admin-only group management endpoints.
type AdminGroupsHandler struct {
store GroupStore
}
// NewAdminGroupsHandler creates an AdminGroupsHandler backed by the given
// group store.
func NewAdminGroupsHandler(store GroupStore) *AdminGroupsHandler {
return &AdminGroupsHandler{store: store}
}
// --- Request/Response types ---
// createAdminGroupRequest represents the JSON body for POST /admin/groups.
type createAdminGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
LibraryIDs []int `json:"library_ids"` // null/absent = all libraries
MaxStreams *int `json:"max_streams"`
MaxTranscodes *int `json:"max_transcodes"`
MaxProfiles *int `json:"max_profiles"`
MaxPlaybackQuality *string `json:"max_playback_quality"`
DownloadAllowed *bool `json:"download_allowed"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed"`
}
// updateAdminGroupRequest represents the JSON body for PATCH /admin/groups/{id}.
// Absent fields are left unchanged.
type updateAdminGroupRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Permissions optionalStringSliceField `json:"permissions,omitempty"`
LibraryIDs optionalIntSliceField `json:"library_ids,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
// adminGroupResponse represents a group in admin JSON responses.
type adminGroupResponse struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Permissions []string `json:"permissions"`
LibraryIDs []int `json:"library_ids"` // null = all libraries
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
MaxPlaybackQuality string `json:"max_playback_quality"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type adminGroupListResponse struct {
Groups []adminGroupResponse `json:"groups"`
}
type adminGroupMemberRow struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Enabled bool `json:"enabled"`
}
type adminGroupMembersResponse struct {
Members []adminGroupMemberRow `json:"members"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func toAdminGroupResponse(g *models.Group, memberCount int) adminGroupResponse {
return adminGroupResponse{
ID: g.ID,
Slug: g.Slug,
Name: g.Name,
Description: g.Description,
BuiltIn: g.BuiltIn,
Permissions: append([]string{}, g.Permissions...),
LibraryIDs: g.LibraryIDs,
MaxStreams: g.MaxStreams,
MaxTranscodes: g.MaxTranscodes,
MaxProfiles: g.MaxProfiles,
MaxPlaybackQuality: g.MaxPlaybackQuality,
DownloadAllowed: g.DownloadAllowed,
DownloadTranscodeAllowed: g.DownloadTranscodeAllowed,
MemberCount: memberCount,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
}
}
// --- Helpers ---
// writeGroupError maps group repository sentinel errors to API responses.
func writeGroupError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, auth.ErrGroupNotFound):
writeError(w, http.StatusNotFound, "not_found", "Group not found")
case errors.Is(err, auth.ErrUnknownUser):
writeError(w, http.StatusNotFound, "unknown_user", "User not found")
case errors.Is(err, auth.ErrBuiltInGroup):
writeError(w, http.StatusConflict, "built_in_group", "Built-in groups cannot be deleted")
case errors.Is(err, auth.ErrAdminPermRequired):
writeError(w, http.StatusConflict, "admin_permission_required", "The administrators group must keep the admin permission")
case errors.Is(err, auth.ErrLastAdministrator):
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
case errors.Is(err, auth.ErrDuplicate):
writeError(w, http.StatusConflict, "duplicate", "A group with that name already exists")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Group operation failed")
}
}
func parseGroupIDParam(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
id, err := strconv.Atoi(chi.URLParam(r, name))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+name)
return 0, false
}
return id, true
}
// --- Handler methods ---
// HandleList handles GET /admin/groups.
func (h *AdminGroupsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
groups, err := h.store.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list groups")
return
}
resp := adminGroupListResponse{Groups: make([]adminGroupResponse, 0, len(groups))}
for i := range groups {
resp.Groups = append(resp.Groups, toAdminGroupResponse(&groups[i].Group, groups[i].MemberCount))
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreate handles POST /admin/groups.
func (h *AdminGroupsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
var req createAdminGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Group name is required")
return
}
permissions, err := auth.NormalizePermissions(req.Permissions)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
group, err := h.store.Create(r.Context(), models.CreateGroupInput{
Name: name,
Description: req.Description,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
MaxPlaybackQuality: req.MaxPlaybackQuality,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
})
if err != nil {
writeGroupError(w, err)
return
}
writeJSON(w, http.StatusCreated, toAdminGroupResponse(group, 0))
}
// HandleGet handles GET /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
group, err := h.store.GetByID(r.Context(), id)
if err != nil {
writeGroupError(w, err)
return
}
memberCount, err := h.store.MemberCount(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
return
}
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
}
// HandleUpdate handles PATCH /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
var req updateAdminGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Group name cannot be empty")
return
}
input := models.UpdateGroupInput{
Name: req.Name,
Description: req.Description,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
MaxPlaybackQuality: req.MaxPlaybackQuality,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
}
if req.Permissions.Set {
permissions, err := auth.NormalizePermissions(req.Permissions.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
input.Permissions = &permissions
}
group, err := h.store.Update(r.Context(), id, input)
if err != nil {
writeGroupError(w, err)
return
}
memberCount, err := h.store.MemberCount(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
return
}
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
}
// HandleDelete handles DELETE /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
if err := h.store.Delete(r.Context(), id); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListMembers handles GET /admin/groups/{id}/members.
func (h *AdminGroupsHandler) HandleListMembers(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
if _, err := h.store.GetByID(r.Context(), id); err != nil {
writeGroupError(w, err)
return
}
q := r.URL.Query()
offset := 0
if v := q.Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
members, total, err := h.store.ListMembers(r.Context(), id, offset, limit)
if err != nil {
writeGroupError(w, err)
return
}
resp := adminGroupMembersResponse{
Members: make([]adminGroupMemberRow, 0, len(members)),
Total: total,
Offset: offset,
Limit: limit,
}
for _, m := range members {
resp.Members = append(resp.Members, adminGroupMemberRow{
UserID: m.UserID,
Username: m.Username,
Email: m.Email,
Enabled: m.Enabled,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleAddMember handles PUT /admin/groups/{id}/members/{userID}.
func (h *AdminGroupsHandler) HandleAddMember(w http.ResponseWriter, r *http.Request) {
groupID, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
userID, ok := parseGroupIDParam(w, r, "userID")
if !ok {
return
}
if err := h.store.AddMember(r.Context(), groupID, userID); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRemoveMember handles DELETE /admin/groups/{id}/members/{userID}.
func (h *AdminGroupsHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
groupID, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
userID, ok := parseGroupIDParam(w, r, "userID")
if !ok {
return
}
if err := h.store.RemoveMember(r.Context(), groupID, userID); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
-550
View File
@@ -1,550 +0,0 @@
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)
})
}
}
+12 -14
View File
@@ -37,7 +37,6 @@ 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 {
@@ -123,14 +122,13 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
return
}
viewerUserID := apimw.GetUserID(r.Context())
viewerIsAdmin := isAdminRequest(r, h.Users)
if !canReadAdminJob(viewerUserID, viewerIsAdmin, job) {
claims := apimw.GetClaims(r.Context())
if !canReadAdminJob(claims, job) {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
writeJSON(w, http.StatusOK, adminJobToResponseForViewer(r, job, h.store, viewerIsAdmin))
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
}
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
@@ -266,19 +264,19 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
return resp
}
func adminJobToResponseForViewer(
func adminJobToResponseForClaims(
r *http.Request,
job *models.AdminJob,
store AdminJobArtifactStore,
viewerIsAdmin bool,
claims *auth.Claims,
) adminJobResponse {
response := adminJobToResponse(r, job, store)
sanitizeAdminJobResponseForViewer(&response, viewerIsAdmin)
sanitizeAdminJobResponseForClaims(&response, claims)
return response
}
func sanitizeAdminJobResponseForViewer(response *adminJobResponse, viewerIsAdmin bool) {
if response == nil || viewerIsAdmin {
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
if response == nil || (claims != nil && claims.Role == "admin") {
return
}
response.RequestPayload = json.RawMessage(`{}`)
@@ -386,12 +384,12 @@ func currentAdminUserID(r *http.Request) int {
return claims.UserID
}
func canReadAdminJob(viewerUserID int, viewerIsAdmin bool, job *models.AdminJob) bool {
if viewerUserID == 0 || job == nil {
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
if claims == nil || job == nil {
return false
}
if viewerIsAdmin {
if claims.Role == "admin" {
return true
}
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == viewerUserID
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
}
+12 -6
View File
@@ -13,40 +13,46 @@ 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(1, true, job) {
if !canReadAdminJob(claims, 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(2, false, job) {
if !canReadAdminJob(claims, 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(2, false, job) {
if canReadAdminJob(claims, 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(3, false, job) {
if canReadAdminJob(claims, job) {
t.Fatal("non-admin should not read another user's item refresh job")
}
}
func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
job := &models.AdminJob{
JobType: adminjob.JobTypeItemRefresh,
CreatedByUserID: 2,
@@ -60,7 +66,7 @@ func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *tes
PublicURL: "https://example.test/public",
}
resp := adminJobToResponseForViewer(nil, job, nil, false)
resp := adminJobToResponseForClaims(nil, job, nil, claims)
if string(resp.RequestPayload) != `{}` {
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
@@ -0,0 +1,143 @@
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: playback.IssuedByKindAdmin}
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
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: playback.IssuedByKindAdmin}
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
deadline := boundedPlaybackControlDeadline(req.DeadlineMS)
command.DeadlineMS = int(deadline / time.Millisecond)
@@ -326,6 +326,47 @@ 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,
+63 -78
View File
@@ -1,82 +1,34 @@
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"
newGroups := []int{1, 2}
sameGroups := []int{2, 1, 1} // order and duplicates must not matter
emptyGroups := []int{}
maxStreams := 4
permissions := []string{"metadata_curation"}
samePermissions := []string{"download"}
current := &models.User{
Enabled: false,
GroupIDs: []int{1, 2},
Role: "user",
Permissions: []string{"download"},
Enabled: false,
LibraryIDs: []int{1},
MaxPlaybackQuality: "original",
}
tests := []struct {
@@ -84,6 +36,26 @@ 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},
@@ -95,15 +67,30 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
want: false,
},
{
name: "groups unchanged",
in: models.UpdateUserInput{GroupIDs: &sameGroups},
name: "library ids does not revoke session",
in: models.UpdateUserInput{LibraryIDs: &libraryIDs},
want: false,
},
{
name: "groups cleared",
in: models.UpdateUserInput{GroupIDs: &emptyGroups},
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},
want: true,
},
{
name: "max playback quality unchanged",
in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality},
want: false,
},
{
name: "password",
in: models.UpdateUserInput{Password: &password},
@@ -111,7 +98,7 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
},
{
name: "non access fields",
in: models.UpdateUserInput{Username: &username},
in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams},
want: false,
},
{
@@ -129,18 +116,16 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
})
}
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)
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)
}
})
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.Run("library ids nil unchanged", func(t *testing.T) {
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
}
})
+2 -2
View File
@@ -344,7 +344,7 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
}
const ttl = 5 * time.Minute
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.SessionID, ttl)
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
return
@@ -527,7 +527,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: auth.RoleForUser(user),
Role: user.Role,
Permissions: auth.EffectivePermissions(user),
DownloadAllowed: user.DownloadAllowed,
}
@@ -24,6 +24,7 @@ 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")
@@ -420,6 +421,7 @@ 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"))
@@ -522,6 +524,7 @@ 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"))
@@ -605,6 +608,7 @@ 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"))
+72 -91
View File
@@ -12,7 +12,8 @@ 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/models"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/gorilla/websocket"
@@ -32,9 +33,10 @@ type activeScanLister interface {
ListActive(ctx context.Context) ([]evt.ScanRun, 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
// 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)
}
type EventsHandler struct {
hub *evt.Hub
@@ -44,11 +46,8 @@ type EventsHandler struct {
scans *evt.ScanRegistry
persistedScans activeScanLister
historyImports historyImportActiveLister
users auth.UserLoader
// revalidateInterval overrides viewerRevalidateInterval in tests; zero
// means the default.
revalidateInterval time.Duration
notifications notificationUnreadCounter
presence presence.Registry
}
func NewEventsHandler(
@@ -59,7 +58,8 @@ func NewEventsHandler(
scans *evt.ScanRegistry,
persistedScans *scanqueue.Service,
historyImports historyImportActiveLister,
users auth.UserLoader,
notificationsSvc *notifications.Service,
presenceReg presence.Registry,
) *EventsHandler {
return &EventsHandler{
hub: hub,
@@ -69,7 +69,8 @@ func NewEventsHandler(
scans: scans,
persistedScans: persistedScans,
historyImports: historyImports,
users: users,
notifications: notificationsSvc,
presence: presenceReg,
}
}
@@ -85,10 +86,6 @@ 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
@@ -101,11 +98,20 @@ 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 := allowedChannelsForViewer(viewerIsAdmin)
allowedChannels := allowedChannelsForRole(claims.Role)
connectionID := ulid.Make().String()
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
Type: "hello",
@@ -137,17 +143,6 @@ 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
@@ -157,33 +152,6 @@ 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
@@ -196,7 +164,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
)
return
case data := <-readMessages:
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, viewerIsAdmin, data, allowedChannels)
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels)
if !ok {
return
}
@@ -218,10 +186,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
if _, subscribed := subscriptions[env.Channel]; !subscribed {
continue
}
if !allowsEventForViewer(claims.UserID, viewerIsAdmin, env) {
if !allowsEventForClaims(claims, env) {
continue
}
if err := h.writeEventFrame(conn, r, claims, viewerIsAdmin, env); err != nil {
if err := h.writeEventFrame(conn, r, claims, env); err != nil {
return
}
}
@@ -232,7 +200,6 @@ 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) {
@@ -317,7 +284,7 @@ func (h *EventsHandler) handleEventsClientMessage(
}
for _, channel := range accepted {
if err := h.writeSnapshotFrame(conn, r, claims, viewerIsAdmin, channel); err != nil {
if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil {
return nil, false, false
}
}
@@ -325,32 +292,15 @@ func (h *EventsHandler) handleEventsClientMessage(
return nextSubs, true, true
}
// 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 {
func allowedChannelsForRole(role string) []evt.EventChannel {
channels := []evt.EventChannel{
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelNotifications,
evt.ChannelRequests,
evt.ChannelUserState,
}
if viewerIsAdmin {
if role == "admin" {
channels = append(channels,
evt.ChannelJobs,
evt.ChannelSessions,
@@ -361,19 +311,31 @@ func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
return channels
}
func allowsEventForViewer(viewerUserID int, viewerIsAdmin bool, env evt.Envelope) bool {
if viewerUserID == 0 {
func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
if claims == nil {
return false
}
if env.AdminOnly && !viewerIsAdmin {
if env.AdminOnly && claims.Role != "admin" {
return false
}
if env.UserID > 0 && !viewerIsAdmin && env.UserID != viewerUserID {
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
}
}
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")
@@ -388,12 +350,33 @@ 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
@@ -441,7 +424,7 @@ func (h *EventsHandler) snapshotForChannel(
if h == nil || h.historyImports == nil {
return json.RawMessage("[]"), nil
}
if viewerIsAdmin {
if claims != nil && claims.Role == "admin" {
runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil)
if err != nil {
return nil, err
@@ -462,10 +445,9 @@ 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, viewerIsAdmin, channel)
data, err := h.snapshotForChannel(r, claims, channel)
if err != nil {
slog.Error(
"events: failed to build initial snapshot",
@@ -491,12 +473,11 @@ 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, viewerIsAdmin, env.Channel)
snapshot, err := h.snapshotForChannel(r, claims, env.Channel)
if err != nil {
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
return err
+105 -150
View File
@@ -3,168 +3,123 @@ package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"slices"
"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"
)
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,
},
// ---------------------------------------------------------------------------
// 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)
}
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)
}
})
if !slices.Contains(channels, evt.ChannelRequests) {
t.Errorf("allowedChannelsForRole(user) missing ChannelRequests; got %v", channels)
}
}
// mutableUserLoader is a swappable auth.UserLoader for revalidation tests.
type mutableUserLoader struct {
mu sync.Mutex
user *models.User
err error
}
func TestAllowedChannelsForRole_AdminContainsAllUserChannelsPlusExtras(t *testing.T) {
userChannels := allowedChannelsForRole("user")
adminChannels := allowedChannelsForRole("admin")
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)
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)
}
// Drain subscribed/snapshot frames until the close arrives.
}
}
// ---------------------------------------------------------------------------
// 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)
}
}
+3
View File
@@ -1800,6 +1800,9 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
if claims == nil {
return false
}
if claims.Role == "admin" {
return true
}
if h == nil || h.UserRepo == nil {
return false
}
-62
View File
@@ -1,62 +0,0 @@
package handlers
// This file holds tri-state JSON field wrappers for PATCH-style request
// bodies. Plain Go pointers can only distinguish two states (absent/nil vs.
// value); these wrappers track three:
//
// - absent: the key was not present in the JSON body (Set = false) —
// the caller should leave the current value unchanged
// - null: the key was present with an explicit JSON null (Set = true)
// - value: the key was present with a concrete value (Set = true)
//
// How null is decoded depends on the wrapper: optionalIntSliceField keeps a
// nil slice (e.g. "all libraries"), optionalStringSliceField normalizes null
// to an empty slice.
import (
"bytes"
"encoding/json"
)
// optionalIntSliceField distinguishes an absent JSON field from an explicit
// null or array value. Absent = don't update; null = nil slice ("all
// libraries"); [] = empty slice ("none").
type optionalIntSliceField struct {
Set bool
Value []int
}
func (f *optionalIntSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = nil
return nil
}
return json.Unmarshal(data, &f.Value)
}
// Ptr returns nil when the field was absent, otherwise a pointer to the
// decoded slice (which may itself be nil for JSON null).
func (f optionalIntSliceField) Ptr() *[]int {
if !f.Set {
return nil
}
value := f.Value
return &value
}
// optionalStringSliceField distinguishes an absent JSON field from an
// explicit null or array value. JSON null decodes to an empty slice.
type optionalStringSliceField struct {
Set bool
Value []string
}
func (f *optionalStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
+3
View File
@@ -288,6 +288,9 @@ 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
+9 -15
View File
@@ -90,6 +90,7 @@ 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",
}))
@@ -97,12 +98,7 @@ func withMarkerAdminClaims(req *http.Request) *http.Request {
func newMarkersHandler(writer ManualMarkerWriter) *MarkersHandler {
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
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
return NewMarkersHandler(files, writer, nil, nil, nil, nil)
}
func TestSetFileMarkersWritesManual(t *testing.T) {
@@ -135,7 +131,6 @@ 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}}`))
@@ -214,13 +209,13 @@ func TestGetItemMarkersUsesFirstAuthorizedFile(t *testing.T) {
func TestGetFileMarkersDoesNotRequireMarkerEditPermission(t *testing.T) {
h := newMarkersHandler(nil)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
7: &models.User{ID: 7, Role: "user", 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, TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleGetFileMarkers(rec, req)
@@ -234,10 +229,10 @@ func TestSetFileMarkersRejectsUserWithoutMarkerEditPermission(t *testing.T) {
writer := &fakeMarkerWriter{}
h := newMarkersHandler(writer)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
}
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, req)
@@ -254,10 +249,10 @@ func TestSetFileMarkersAllowsUserWithMarkerEditPermission(t *testing.T) {
writer := &fakeMarkerWriter{}
h := newMarkersHandler(writer)
h.Users = fakeMarkerUsers{
7: &models.User{ID: 7, Enabled: true, Permissions: []string{"marker_edit"}},
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{"marker_edit"}},
}
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, req)
@@ -279,6 +274,7 @@ 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,
})
@@ -386,7 +382,6 @@ 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}}`))
@@ -408,7 +403,6 @@ func TestSetFileMarkersClearOnlyDoesNotContribute(t *testing.T) {
called := make(chan markers.ContributeOptions, 1)
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
rec := httptest.NewRecorder()
h.HandleSetFileMarkers(rec, markerPutRequest(`{"credits":null}`))
+316
View File
@@ -0,0 +1,316 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// profileResolver is the minimal interface NotificationsHandler needs from the
// userstore provider. Defined on the handler side so tests can stub it without
// importing the full provider.
type profileResolver interface {
ForUser(ctx context.Context, userID int) (userstore.UserStore, error)
}
// NotificationsHandler handles inbox, preferences, and announcement endpoints.
type NotificationsHandler struct {
svc *notifications.Service
profiles profileResolver // nil-tolerant: ChildSafe=false when absent
}
// NewNotificationsHandler creates a new NotificationsHandler.
// provider may be nil; when nil, ChildSafe defaults to false on all requests.
func NewNotificationsHandler(svc *notifications.Service, provider ...profileResolver) *NotificationsHandler {
h := &NotificationsHandler{svc: svc}
if len(provider) > 0 {
h.profiles = provider[0]
}
return h
}
// childSafe resolves the active profile and returns Profile.IsChild.
// Fails closed: an unresolvable profile is treated as a child so restricted
// categories are never leaked on errors. Logs at debug level only — never fails
// the request. Nil resolver / missing ids keep returning false (unauthenticated
// paths never reach here; that's the wiring-absent case).
func (h *NotificationsHandler) childSafe(r *http.Request, userID int, profileID string) bool {
if h.profiles == nil || userID == 0 || profileID == "" {
return false
}
store, err := h.profiles.ForUser(r.Context(), userID)
if err != nil {
slog.Debug("notifications: childSafe: ForUser failed", "user_id", userID, "error", err)
return true // fail closed: treat unknown as child
}
p, err := store.GetProfile(r.Context(), profileID)
if err != nil || p == nil {
slog.Debug("notifications: childSafe: GetProfile failed", "profile_id", profileID, "error", err)
return true // fail closed: treat unknown as child
}
return p.IsChild
}
// ---------------------------------------------------------------------------
// User-scoped inbox endpoints
// ---------------------------------------------------------------------------
// HandleList handles GET /notifications.
// Query params: unread=1, category, cursor (int64), limit (int, default 50, max 100).
// Response: {"items":[...],"next_cursor":<int64|null>}
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
q := r.URL.Query()
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
// keep in sync with the clamp in internal/notifications/store.go List
if limit > 100 {
limit = 100
}
f := notifications.ListFilter{
UserID: userID,
ProfileID: profileID,
Limit: limit,
ChildSafe: h.childSafe(r, userID, profileID),
}
if q.Get("unread") == "1" {
f.UnreadOnly = true
}
if cat := q.Get("category"); cat != "" {
f.Category = notifications.Category(cat)
}
if v := q.Get("cursor"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
f.Cursor = n
}
}
items, err := h.svc.List(r.Context(), f)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
return
}
var nextCursor *int64
if len(items) == limit && limit > 0 {
last := items[len(items)-1].ID
nextCursor = &last
}
writeJSON(w, http.StatusOK, struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}{
Items: items,
NextCursor: nextCursor,
})
}
// HandleUnreadCount handles GET /notifications/unread-count.
// Response: {"count": n}
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
count, err := h.svc.UnreadCount(r.Context(), userID, profileID, h.childSafe(r, userID, profileID))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get unread count")
return
}
writeJSON(w, http.StatusOK, struct {
Count int `json:"count"`
}{Count: count})
}
// markReadRequest is the request body for POST /notifications/read.
type markReadRequest struct {
IDs []int64 `json:"ids"`
All bool `json:"all"`
}
// HandleMarkRead handles POST /notifications/read.
// Body: {"ids":[...]} OR {"all":true}. 400 when neither present.
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req markReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if !req.All && len(req.IDs) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Provide ids or all:true")
return
}
if req.All {
if err := h.svc.MarkAllRead(r.Context(), userID, profileID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark all read")
return
}
} else {
if err := h.svc.MarkRead(r.Context(), userID, profileID, req.IDs); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDismiss handles POST /notifications/{id}/dismiss.
// 400 for invalid id; 404 on ErrNotFound; 204 on success.
func (h *NotificationsHandler) HandleDismiss(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
rawID := chi.URLParam(r, "id")
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid notification id")
return
}
if err := h.svc.Dismiss(r.Context(), userID, profileID, id); err != nil {
if errors.Is(err, notifications.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to dismiss notification")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleGetPreferences handles GET /notifications/preferences.
// Response: {"preferences":[{category,enabled},...]}
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
prefs, err := h.svc.GetPreferences(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get preferences")
return
}
writeJSON(w, http.StatusOK, struct {
Preferences []notifications.Preference `json:"preferences"`
}{Preferences: prefs})
}
// putPreferencesRequest is the request body for PUT /notifications/preferences.
type putPreferencesRequest struct {
Preferences []notifications.Preference `json:"preferences"`
}
// HandlePutPreferences handles PUT /notifications/preferences.
// 400 on empty or invalid category; 204 on success.
func (h *NotificationsHandler) HandlePutPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
var req putPreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if len(req.Preferences) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "preferences must not be empty")
return
}
if err := h.svc.SetPreferences(r.Context(), userID, req.Preferences); err != nil {
if errors.Is(err, notifications.ErrInvalidCategory) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save preferences")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Admin announcement endpoints
// ---------------------------------------------------------------------------
// HandleListAnnouncements handles GET /admin/announcements.
// Response: {"items":[...]}
func (h *NotificationsHandler) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.ListAnnouncements(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list announcements")
return
}
if items == nil {
items = []*notifications.Announcement{}
}
writeJSON(w, http.StatusOK, struct {
Items []*notifications.Announcement `json:"items"`
}{Items: items})
}
// HandleCreateAnnouncement handles POST /admin/announcements.
// Decodes an Announcement, sets CreatedBy from claims, publishes; 400 on
// validation error; 201 with the announcement on success.
func (h *NotificationsHandler) HandleCreateAnnouncement(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
// Announcements fan out one row per (user, profile); cap the request body so
// a large title/body cannot be amplified into the whole server's inboxes.
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
var a notifications.Announcement
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if claims != nil {
userID := claims.UserID
a.CreatedBy = &userID
}
if err := h.svc.PublishAnnouncement(r.Context(), &a); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusCreated, &a)
}
// HandleDeleteAnnouncement handles DELETE /admin/announcements/{id}.
// 404 on ErrNotFound; 204 on success.
func (h *NotificationsHandler) HandleDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
rawID := chi.URLParam(r, "id")
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid announcement id")
return
}
if err := h.svc.DeleteAnnouncement(r.Context(), id); err != nil {
if errors.Is(err, notifications.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Announcement not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete announcement")
return
}
w.WriteHeader(http.StatusNoContent)
}
+673
View File
@@ -0,0 +1,673 @@
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
+43 -19
View File
@@ -22,6 +22,7 @@ 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"
@@ -129,11 +130,12 @@ type PlaybackHandler struct {
RealtimeHub *playback.RealtimeHub
CommandTracker *playback.CommandTracker
CommandDispatcher *playback.CommandDispatcher
FFmpegPath string
HWAccel string
HWDevice string
TranscodeDir string
FFmpegLogSink playback.FFmpegLogSink
// 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
transcodeMu sync.RWMutex
transcodes map[string]*playback.TranscodeSession
realtimeCommandMu sync.Mutex
@@ -156,7 +158,6 @@ 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),
}
@@ -179,6 +180,19 @@ 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) {
@@ -189,7 +203,7 @@ func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
}
h.transcodeMu.RUnlock()
return playback.CleanupOrphanedTranscodeDirs(h.TranscodeDir, active)
return playback.CleanupOrphanedTranscodeDirs(h.playbackConfig().TranscodeDir, active)
}
// playbackThresholds reads the playback.watched_threshold and
@@ -614,9 +628,9 @@ func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int {
return directPlayAudioTrackIndex(file)
}
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader) playback.AdminSettings {
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader, transcodeEnabled bool) playback.AdminSettings {
settings := playback.AdminSettings{
TranscodeEnabled: true,
TranscodeEnabled: transcodeEnabled,
}
if repo != nil {
if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" {
@@ -661,7 +675,7 @@ func (h *PlaybackHandler) resolveCapabilityPlaybackSelection(
}
audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex)
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo)
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo, h.playbackConfig().TranscodeEnabled)
method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings)
if requestedFile.Resolution == "2160p" &&
@@ -1430,10 +1444,14 @@ 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); all other text formats get ".vtt".
// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side
// bitmap rendering (libpgs); all other text formats get ".vtt".
func subtitleURLExt(codec string) string {
if playback.IsASS(codec) {
switch {
case playback.IsASS(codec):
return ".ass"
case playback.IsPGS(codec):
return ".sup"
}
return ".vtt"
}
@@ -1460,7 +1478,10 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su
embeddedOffset := len(file.ExternalSubtitles)
for i, track := range file.SubtitleTracks {
if playback.NeedsBurnIn(track.Codec) {
// 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) {
continue
}
@@ -2064,7 +2085,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
TargetCodecAudio: req.TargetCodecAudio,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
HWAccel: h.HWAccel,
HWAccel: h.playbackConfig().HWAccel,
AudioTrackIndex: session.AudioTrackIndex,
SubtitleTrackIndex: req.SubtitleTrackIndex,
SubtitleBurnIn: req.SubtitleBurnIn,
@@ -2130,14 +2151,17 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
"No transcode node is available and local transcode fallback is disabled")
return
}
if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil {
// 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 {
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(h.TranscodeDir, req.SessionID),
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
SessionID: req.SessionID,
SourceVideoCodec: file.CodecVideo,
SeekSeconds: req.SeekSeconds,
@@ -2147,9 +2171,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
TargetCodecAudio: req.TargetCodecAudio,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
FFmpegPath: h.FFmpegPath,
HWAccel: h.HWAccel,
HWDevice: h.HWDevice,
FFmpegPath: playbackCfg.FFmpegPath,
HWAccel: playbackCfg.HWAccel,
HWDevice: playbackCfg.HWDevice,
AudioTrackIndex: session.AudioTrackIndex,
SubtitleTrackIndex: req.SubtitleTrackIndex,
SubtitleBurnIn: req.SubtitleBurnIn,
@@ -0,0 +1,81 @@
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)
}
}
+15 -6
View File
@@ -18,6 +18,7 @@ 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"
@@ -162,7 +163,7 @@ func newPlaybackTestStore(t *testing.T) userstore.UserStore {
func newAuthorizedPlaybackContext() context.Context {
ctx := context.Background()
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
return apimw.SetProfileID(ctx, "profile-1")
}
@@ -182,6 +183,16 @@ 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()
@@ -1173,8 +1184,7 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
handler.TranscodeDir = t.TempDir()
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), 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())
@@ -1239,8 +1249,7 @@ func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) {
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
handler.TranscodeDir = t.TempDir()
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
transcodeReq := httptest.NewRequest(
"POST",
@@ -1403,7 +1412,7 @@ func TestHandleStartTranscode_KeepsSessionWhenStartupFailsForNonMissingReason(t
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.SessionSyncer = syncer
handler.AdminStore = adminStore
handler.TranscodeDir = writePlaybackTestMediaFile(t, "occupied-transcode-dir")
handler.PlaybackConfig = playbackTestConfig("", 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())
+2 -8
View File
@@ -124,12 +124,6 @@ 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.
@@ -139,7 +133,7 @@ func (h *ProfileHandler) isAdminProfileRequest(r *http.Request) bool {
// 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 h.isAdminProfileRequest(r) {
if apimw.IsAdmin(ctx) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
@@ -317,7 +311,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 && !h.isAdminProfileRequest(r) &&
if isBootstrap && !apimw.IsAdmin(r.Context()) &&
(req.IsChild || req.MaxContentRating != "" ||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
req.MaxPlaybackQuality != "") {
+2 -1
View File
@@ -45,6 +45,7 @@ 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,
})
@@ -98,7 +99,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, Enabled: true, IsAdmin: true},
user: &models.User{ID: 1, MaxProfiles: 1},
}
req := newAuthorizedProfileRequestWithRole(
+234
View File
@@ -0,0 +1,234 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/push"
)
// maxPushTokenLen bounds a registered push token. APNs/FCM device tokens are
// short; a Web Push subscription JSON (endpoint + keys) is ~1 KB.
const maxPushTokenLen = 8192
// pushRegistry is the minimal interface PushHandler needs from *push.Store.
type pushRegistry interface {
RegisterToken(ctx context.Context, userID int, profileID, deviceID, transport, token string) error
RevokeToken(ctx context.Context, userID int, profileID, deviceID string) error
SetDeviceEnabled(ctx context.Context, userID int, deviceID string, enabled bool) error
ListDevices(ctx context.Context, userID int) ([]push.DeviceInfo, error)
}
// pushConfigReader is the minimal interface PushHandler needs from *push.Config.
type pushConfigReader interface {
WebPush(ctx context.Context) push.WebPushConfig
Status(ctx context.Context) push.Status
}
// pushNotifier is the minimal interface PushHandler needs to send system notifications.
type pushNotifier interface {
CreateSystem(ctx context.Context, userID int, typ, title, body string)
}
// PushHandler handles push notification registration, management, and status endpoints.
type PushHandler struct {
reg pushRegistry
config pushConfigReader
notifier pushNotifier
}
// NewPushHandler creates a new PushHandler. notifier may be nil (test-push endpoint
// returns 503 when nil).
func NewPushHandler(reg pushRegistry, config pushConfigReader, notifier pushNotifier) *PushHandler {
return &PushHandler{reg: reg, config: config, notifier: notifier}
}
// registerRequest is the request body for HandleRegister.
type registerRequest struct {
Transport string `json:"transport"`
Token string `json:"token"`
}
// deviceContext extracts the authenticated user, device id, and active profile
// required by the profile-scoped push device endpoints. It writes a 400 and
// returns ok=false when any of the three is missing.
func (h *PushHandler) deviceContext(w http.ResponseWriter, r *http.Request) (userID int, deviceID, profileID string, ok bool) {
userID = apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Authentication required")
return 0, "", "", false
}
deviceID = strings.TrimSpace(r.Header.Get(deviceIDHeader))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Silo-Device-Id header is required")
return 0, "", "", false
}
profileID = strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return 0, "", "", false
}
return userID, deviceID, profileID, true
}
// HandleRegister handles PUT /notifications/push/device.
// Requires userID (from JWT claims), X-Silo-Device-Id, and X-Profile-Id headers.
// Body: {"transport": "apns"|"fcm"|"webpush", "token": "..."}
// Returns 204 on success.
func (h *PushHandler) HandleRegister(w http.ResponseWriter, r *http.Request) {
userID, deviceID, profileID, ok := h.deviceContext(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
switch req.Transport {
case push.TransportAPNs, push.TransportFCM, push.TransportWebPush:
// valid
default:
writeError(w, http.StatusBadRequest, "bad_request", "transport must be one of apns, fcm, webpush")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "bad_request", "token must not be empty")
return
}
// Cap token length: APNs/FCM device tokens are short; a Web Push subscription
// JSON (endpoint + keys) is ~1 KB. 8 KB is generous and bounds the stored row.
if len(req.Token) > maxPushTokenLen {
writeError(w, http.StatusBadRequest, "bad_request", "token too large")
return
}
if err := h.reg.RegisterToken(r.Context(), userID, profileID, deviceID, req.Transport, req.Token); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRevoke handles DELETE /notifications/push/device.
// Requires userID, X-Silo-Device-Id, and X-Profile-Id headers.
// Returns 204 on success.
func (h *PushHandler) HandleRevoke(w http.ResponseWriter, r *http.Request) {
userID, deviceID, profileID, ok := h.deviceContext(w, r)
if !ok {
return
}
if err := h.reg.RevokeToken(r.Context(), userID, profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke push token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListDevices handles GET /notifications/push/devices.
// Response: {"devices": [...]}
func (h *PushHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
devices, err := h.reg.ListDevices(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
return
}
if devices == nil {
devices = []push.DeviceInfo{}
}
writeJSON(w, http.StatusOK, struct {
Devices []push.DeviceInfo `json:"devices"`
}{Devices: devices})
}
// toggleDeviceRequest is the request body for HandleToggleDevice.
type toggleDeviceRequest struct {
Enabled *bool `json:"enabled"`
}
// HandleToggleDevice handles PUT /notifications/push/devices/{device_id}.
// Body: {"enabled": true|false}
// Returns 204 on success, 404 if device not found.
func (h *PushHandler) HandleToggleDevice(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
deviceID := chi.URLParam(r, "device_id")
var req toggleDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Enabled == nil {
writeError(w, http.StatusBadRequest, "bad_request", "enabled is required")
return
}
if err := h.reg.SetDeviceEnabled(r.Context(), userID, deviceID, *req.Enabled); err != nil {
if errors.Is(err, push.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Device not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleWebPushKey handles GET /notifications/push/webpush-key.
// Response: {"vapid_public_key": "..."} — empty string when not configured.
func (h *PushHandler) HandleWebPushKey(w http.ResponseWriter, r *http.Request) {
cfg := h.config.WebPush(r.Context())
writeJSON(w, http.StatusOK, struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}{VAPIDPublicKey: cfg.VAPIDPublic})
}
// HandleAdminStatus handles GET /admin/push/status.
// Response: {"apns": bool, "fcm": bool, "webpush": bool}
func (h *PushHandler) HandleAdminStatus(w http.ResponseWriter, r *http.Request) {
status := h.config.Status(r.Context())
writeJSON(w, http.StatusOK, status)
}
// HandleGenerateVAPIDKeys handles POST /admin/push/generate-vapid-keys.
// Generates a fresh VAPID key pair and returns it as JSON.
// Response: {"vapid_public": "...", "vapid_private": "..."}
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
// GenerateVAPIDKeys returns (privateKey, publicKey string, err error).
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
return
}
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
}
// HandleSendTestPush handles POST /admin/push/test.
// Sends a system test push notification to the authenticated admin user.
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
if h.notifier == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
return
}
h.notifier.CreateSystem(r.Context(), userID, "system.test_push", "Test push",
"If you can see this on a device, push delivery is working.")
w.WriteHeader(http.StatusAccepted)
}
+496
View File
@@ -0,0 +1,496 @@
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
}())
}
+30 -32
View File
@@ -13,7 +13,6 @@ import (
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
@@ -51,7 +50,6 @@ type RequestService interface {
type RequestsHandler struct {
service RequestService
Users auth.UserLoader // for server-side admin checks
}
func NewRequestsHandler(service RequestService) *RequestsHandler {
@@ -59,7 +57,7 @@ func NewRequestsHandler(service RequestService) *RequestsHandler {
}
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -82,7 +80,7 @@ func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -97,7 +95,7 @@ func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -114,7 +112,7 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R
}
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -129,7 +127,7 @@ func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -144,7 +142,7 @@ func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -159,7 +157,7 @@ func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Reques
}
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -178,7 +176,7 @@ func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -197,7 +195,7 @@ func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Req
}
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -217,7 +215,7 @@ func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -236,7 +234,7 @@ func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -254,7 +252,7 @@ func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -269,7 +267,7 @@ func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -282,7 +280,7 @@ func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -297,7 +295,7 @@ func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -310,7 +308,7 @@ func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -332,7 +330,7 @@ func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request)
}
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -354,7 +352,7 @@ func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -367,7 +365,7 @@ func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
}
func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, true)
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
@@ -380,7 +378,7 @@ func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request
}
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -393,7 +391,7 @@ func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Reque
}
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -411,7 +409,7 @@ func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Re
}
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -426,7 +424,7 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.
}
func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -444,7 +442,7 @@ func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -463,7 +461,7 @@ func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -475,7 +473,7 @@ func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http
}
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -498,7 +496,7 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r
}
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -515,7 +513,7 @@ func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Requ
}
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := h.requestViewer(w, r, false)
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
@@ -537,7 +535,7 @@ func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusOK, updated)
}
func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
func 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")
@@ -551,7 +549,7 @@ func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request,
return mediarequests.Viewer{
UserID: claims.UserID,
ProfileID: profileID,
IsAdmin: isAdminRequest(r, h.Users),
IsAdmin: claims.Role == "admin",
}, true
}
+1
View File
@@ -147,6 +147,7 @@ func authedRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
+2 -2
View File
@@ -891,8 +891,8 @@ func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *ht
allowCustom = v == "true"
}
isAdmin := false
if h.UserRepo != nil {
isAdmin = isAdminRequest(r, h.UserRepo)
if claims := apimw.GetClaims(r.Context()); claims != nil {
isAdmin = claims.Role == "admin"
}
for _, o := range req.Overrides {
+4 -4
View File
@@ -452,7 +452,7 @@ func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testin
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Authenticate as a non-admin profile.
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", 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{UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", 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{UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", 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{UserID: 1})
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
ctx = apimw.SetProfileID(ctx, "p1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
+1 -1
View File
@@ -118,7 +118,7 @@ func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest)
if command.Reason == "" {
command.Reason = "server_restart_requested"
}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
return command, 0, nil, nil
})
+1 -1
View File
@@ -27,7 +27,7 @@ func TestHandleSessionWebSocket_RequiresHelloBeforeRealtimeReady(t *testing.T) {
router := chi.NewRouter()
router.Get("/playback/ws/{session_id}", func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
handler.HandleSessionWebSocket(w, r.WithContext(ctx))
})
+42 -13
View File
@@ -12,6 +12,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/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"
@@ -31,10 +32,20 @@ type StreamHandler struct {
EventsHub *evt.Hub
AdminStore PlaybackAdminStore
SessionSyncer PlaybackSessionSyncer
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
// 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 ""
}
// NewStreamHandler creates a new StreamHandler backed by the given session
@@ -193,7 +204,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")
@@ -208,7 +219,10 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
// Check embedded tracks.
if embeddedIndex < len(file.SubtitleTracks) {
track := file.SubtitleTracks[embeddedIndex]
if playback.NeedsBurnIn(track.Codec) {
// 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) {
writeError(w, http.StatusBadRequest, "bad_request",
"Bitmap subtitle tracks cannot be extracted as text")
return
@@ -324,7 +338,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,
@@ -406,12 +420,24 @@ 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"
if playback.IsASS(track.Codec) {
switch {
case playback.IsASS(track.Codec):
outFormat = "ass"
case playback.IsPGS(track.Codec):
outFormat = "sup"
}
seek := subtitleSeekPosition(r, session)
duration := subtitleWindowDuration(r)
// 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)
}
slog.InfoContext(r.Context(), "subtitle stream requested",
"file_id", file.ID,
"embedded_index", embeddedIndex,
@@ -422,9 +448,12 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
"duration_seconds", duration,
)
if outFormat == "ass" {
switch outFormat {
case "ass":
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
} else {
case "sup":
w.Header().Set("Content-Type", "application/octet-stream")
default:
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
}
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -437,7 +466,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 {
+1 -5
View File
@@ -11,7 +11,6 @@ import (
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
@@ -27,9 +26,6 @@ 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.
@@ -158,7 +154,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 !isAdminRequest(r, h.Users) {
if !apimw.IsAdmin(ctx) {
return false
}
profileID := apimw.GetProfileID(ctx)
+1 -3
View File
@@ -12,7 +12,6 @@ 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"
)
@@ -64,7 +63,6 @@ type SubtitleSearchHandler struct {
repo subtitles.Repository
mediaResolver SubtitleMediaResolver
FileAuthorizer *MediaFileAuthorizer
Users auth.UserLoader // for server-side admin checks
}
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
@@ -336,8 +334,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,6 +56,7 @@ 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)
+23 -28
View File
@@ -19,9 +19,6 @@ 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)
@@ -38,25 +35,28 @@ 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 auth.UserLoader // nil if API keys not configured
userLoader auth.UserLoader // nil if no user store; RequireAdmin then denies
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured
}
// NewAuthMiddleware creates a new AuthMiddleware with the given token validator
// and session validator.
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul auth.UserLoader, ul auth.UserLoader) *AuthMiddleware {
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware {
return &AuthMiddleware{
tokenValidator: tv,
sessionValidator: sv,
apiKeyValidator: akv,
apiKeyUserLoader: akul,
userLoader: ul,
}
}
@@ -105,6 +105,7 @@ 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,
@@ -143,28 +144,23 @@ func (am *AuthMiddleware) RequireAuth(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 {
// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if am.userLoader == nil {
if claims.Role != "admin" {
writeForbidden(w, "Admin access required")
return
}
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))
next.ServeHTTP(w, r)
})
}
@@ -185,13 +181,12 @@ func GetClaims(ctx context.Context) *auth.Claims {
return claims
}
// 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
// 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"
}
// GetUserID retrieves the user ID from the JWT claims in the context.
-125
View File
@@ -1,125 +0,0 @@
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)
}
}
})
}
}
+12 -44
View File
@@ -4,8 +4,6 @@ import (
"context"
"net/http"
"strings"
"github.com/Silo-Server/silo-server/internal/auth"
)
// DemoSettingsReader is the subset of ServerSettingsStore needed by DemoGuard.
@@ -22,12 +20,11 @@ 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, users auth.UserLoader) *DemoGuard {
return &DemoGuard{settings: settings, users: users}
func NewDemoGuard(settings DemoSettingsReader) *DemoGuard {
return &DemoGuard{settings: settings}
}
// blockedRoute defines a method + path prefix combination that is blocked in demo mode.
@@ -62,8 +59,14 @@ 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) {
@@ -71,51 +74,16 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
}
for _, m := range br.methods {
if r.Method == m {
blocked = true
break
writeDemoBlocked(w)
return
}
}
if blocked {
break
}
}
if !blocked {
next.ServeHTTP(w, r)
return
}
// 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)
next.ServeHTTP(w, r)
})
}
// 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)
+9 -11
View File
@@ -40,28 +40,26 @@ func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler)
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role == "admin" {
next.ServeHTTP(w, r)
return
}
if m == nil || m.users == nil || m.libraries == nil {
writeForbidden(w, "Metadata curation permission required")
return
}
user, err := m.users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled {
writeForbidden(w, "Metadata curation permission required")
return
}
// Admins bypass the per-library restriction checks below.
if user.IsAdmin {
next.ServeHTTP(w, r)
return
}
contentID := chi.URLParam(r, "id")
if contentID == "" {
writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
user, err := m.users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !user.Enabled {
writeForbidden(w, "Metadata curation permission required")
return
}
if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) {
writeForbidden(w, "Metadata curation permission required")
return
+15 -15
View File
@@ -30,16 +30,16 @@ func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Conte
return f.ids, f.err
}
func requestWithItemID() *http.Request {
func requestWithItemID(role string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil)
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess})
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, 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) int {
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int {
mw := NewPermissionMiddleware(
fakePermissionUserLoader{user: user},
fakeTargetLibraryResolver{ids: libraryIDs},
@@ -48,52 +48,52 @@ func runMetadataCurationMiddleware(user *models.User, libraryIDs []int) int {
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
next.ServeHTTP(rec, requestWithItemID())
next.ServeHTTP(rec, requestWithItemID(role))
return rec.Code
}
func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) {
code := runMetadataCurationMiddleware(&models.User{ID: 7, Enabled: true, IsAdmin: true}, nil)
code := runMetadataCurationMiddleware(nil, nil, "admin")
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) {
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
code := runMetadataCurationMiddleware(user, []int{1})
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
code := runMetadataCurationMiddleware(user, []int{1}, "user")
if code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
}
}
func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) {
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2})
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) {
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 3})
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")
if code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
}
}
func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) {
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2})
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
if code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
}
}
func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) {
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, nil)
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
code := runMetadataCurationMiddleware(user, nil, "user")
if code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", code, http.StatusNotFound)
}
-6
View File
@@ -46,12 +46,6 @@ 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)
+219 -99
View File
@@ -48,6 +48,8 @@ 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"
@@ -73,7 +75,13 @@ import (
// Dependencies holds all shared dependencies that handlers need.
type Dependencies struct {
Config *config.Config
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))
BootstrapSensitiveConfigured map[string]bool
BootstrapSensitiveValues map[string]string
AppContext context.Context
@@ -169,6 +177,23 @@ 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.
@@ -177,6 +202,17 @@ 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.
@@ -262,6 +298,12 @@ 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,
@@ -277,7 +319,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, userRepo)
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo)
profileTokenService = access.NewProfileTokenService(deps.Config.Auth.JWTSecret, 0)
if deps.UserStoreProvider != nil {
viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService)
@@ -306,11 +348,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Build demo guard middleware if server settings are available.
var demoGuard *apimw.DemoGuard
if settingsRepo != nil {
var demoUsers auth.UserLoader
if userRepo != nil {
demoUsers = userRepo
}
demoGuard = apimw.NewDemoGuard(settingsRepo, demoUsers)
demoGuard = apimw.NewDemoGuard(settingsRepo)
}
// Build library handler if folder repo is available.
@@ -478,14 +516,12 @@ 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 {
@@ -670,13 +706,13 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.JWTSecret = deps.Config.Auth.JWTSecret
}
if deps.Config != nil {
playbackHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
playbackHandler.HWAccel = deps.Config.Playback.HWAccel
playbackHandler.TranscodeDir = deps.Config.Playback.TranscodeDir
playbackHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil {
slog.Warn("playback transcode cleanup failed", "dir", playbackHandler.TranscodeDir, "error", err)
slog.Warn("playback transcode cleanup failed", "dir", deps.Config.Playback.TranscodeDir, "error", err)
} else if cleaned > 0 {
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", playbackHandler.TranscodeDir, "count", cleaned)
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", deps.Config.Playback.TranscodeDir, "count", cleaned)
}
}
playbackHandler.ProbeEnsurer = deps.ProbeEnsurer
@@ -739,19 +775,17 @@ func NewRouter(deps Dependencies) chi.Router {
streamHandler.S3Bucket = deps.S3Public.Bucket()
}
if streamHandler != nil && deps.Config != nil {
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
streamHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
}
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
@@ -785,9 +819,6 @@ 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(
@@ -917,9 +948,6 @@ 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 {
@@ -931,58 +959,40 @@ 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(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,
})
aiClient = llm.NewClient(llmConfigFromServer(deps.Config))
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 {
// 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,
aiCfg, disabledGateway := effectiveSubtitleAIConfig(deps.Config)
if disabledGateway != "" {
warnChatOnlyGateway(disabledGateway)
}
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),
subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors),
subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds),
aiTranslator,
aiTranscriber,
subtitleManager,
subtitleRepo,
deps.FileRepo,
@@ -992,9 +1002,23 @@ 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).
@@ -1003,12 +1027,7 @@ func NewRouter(deps Dependencies) chi.Router {
mtRepo := metadatatranslation.NewPgRepository(deps.DB)
mtService := metadatatranslation.NewService(
deps.AppContext,
metadatatranslation.Config{
Enabled: deps.Config.MetadataAI.Enabled,
Configured: deps.Config.AI.BaseURL != "",
ChatModel: deps.Config.AI.ChatModel,
OnView: deps.Config.MetadataAI.OnView,
},
metadataAIConfigFromServer(deps.Config),
mtRepo,
mtRepo,
&metadatatranslation.CatalogLocalizationStore{
@@ -1021,6 +1040,11 @@ 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.
@@ -1331,7 +1355,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(authMiddleware.RequireAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
r.With(apimw.RequireAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
})
}
}
@@ -1358,7 +1382,7 @@ func NewRouter(deps Dependencies) chi.Router {
http.NotFound(w, r)
return
}
authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, apiKeyRepo, userRepo)
authenticated, admin := resolveOptionalPluginAccess(r, jwtService, sessionRepo)
deps.PluginHTTPProxy.ServeAsset(w, r.WithContext(plugins.WithPluginAccess(r.Context(), authenticated, admin)), installationID, assetPath)
})
}
@@ -1489,7 +1513,8 @@ func NewRouter(deps Dependencies) chi.Router {
deps.ScanRegistry,
deps.LibraryScanQueue,
historyImportSvc,
userRepo,
deps.NotificationsService,
deps.PresenceRegistry,
)
r.Get("/events/ws", eventsHandler.HandleWebSocket)
}
@@ -1512,7 +1537,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Library management routes (admin-only).
if libraryHandler != nil {
r.Group(func(r chi.Router) {
r.Use(authMiddleware.RequireAdmin)
r.Use(apimw.RequireAdmin)
r.Route("/libraries", func(r chi.Router) {
r.Get("/", libraryHandler.HandleListLibraries)
@@ -1638,6 +1663,36 @@ 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) {
@@ -2024,7 +2079,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Admin routes.
if adminHandler != nil {
r.Route("/admin", func(r chi.Router) {
metadataItemAccess := authMiddleware.RequireAdmin
metadataItemAccess := apimw.RequireAdmin
if permissionMiddleware != nil {
metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem
}
@@ -2051,7 +2106,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
r.Group(func(r chi.Router) {
r.Use(authMiddleware.RequireAdmin)
r.Use(apimw.RequireAdmin)
r.Get("/users", adminHandler.HandleListUsers)
r.Post("/users", adminHandler.HandleCreateUser)
@@ -2073,17 +2128,6 @@ 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)
@@ -2403,6 +2447,22 @@ 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)
@@ -2483,11 +2543,18 @@ func (r *pgSubtitleMediaResolver) GetMediaFileWithMetadata(ctx context.Context,
return &meta, nil
}
// 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 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.
func resolveOptionalPluginAccessUser(
r *http.Request,
jwtService *auth.JWTService,
@@ -2530,7 +2597,7 @@ func resolveOptionalPluginAccessUser(
if err != nil || !user.Enabled {
return false, false, 0
}
return true, user.IsAdmin, user.ID
return true, user.Role == "admin", user.ID
}
claims, err := jwtService.ValidateToken(token)
@@ -2541,14 +2608,7 @@ func resolveOptionalPluginAccessUser(
if err != nil || !valid {
return false, false, 0
}
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
return true, claims.Role == "admin", claims.UserID
}
// NewTMDBCollectionFetcher creates a TMDBCollectionFetcher from an API key.
@@ -2691,3 +2751,63 @@ func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset
}
return entries, nil
}
// llmConfigFromServer derives the shared AI client config from the server
// config. Used at construction and again on every config reload.
func llmConfigFromServer(cfg *config.Config) llm.Config {
return llm.Config{
BaseURL: cfg.AI.BaseURL,
APIKey: cfg.AI.APIKey,
ChatModel: cfg.AI.ChatModel,
ASRBaseURL: cfg.AI.ASRBaseURL,
ASRAPIKey: cfg.AI.ASRAPIKey,
ASRModel: cfg.AI.ASRModel,
}
}
// effectiveSubtitleAIConfig derives the subtitle AI service config from the
// server config. A chat-only gateway (e.g. OpenRouter) cannot produce
// timestamped transcriptions, so transcription is disabled rather than
// letting every job fail; the settings API rejects such values for the ASR
// URL, but the chat base URL legitimately may be one — this catches the
// blank-ASR-URL fallback case. The second return is the offending endpoint
// when that guard fired, empty otherwise.
func effectiveSubtitleAIConfig(cfg *config.Config) (subtitleai.Config, string) {
transcribeEnabled := cfg.SubtitleAI.TranscribeEnabled
effectiveASRBase := cfg.AI.ASRBaseURL
if effectiveASRBase == "" {
effectiveASRBase = cfg.AI.BaseURL
}
disabledGateway := ""
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
transcribeEnabled = false
disabledGateway = effectiveASRBase
}
return subtitleai.Config{
Configured: cfg.AI.BaseURL != "",
TranslateEnabled: cfg.SubtitleAI.Enabled,
TranscribeEnabled: transcribeEnabled,
ChatModel: cfg.AI.ChatModel,
ASRModel: cfg.AI.ASRModel,
BatchSize: cfg.SubtitleAI.BatchSize,
ContextNeighbors: cfg.SubtitleAI.ContextNeighbors,
TranscribeQuotaJobs: cfg.SubtitleAI.TranscribeQuotaJobs,
TranscribeQuotaPeriod: cfg.SubtitleAI.TranscribeQuotaPeriod,
}, disabledGateway
}
func warnChatOnlyGateway(endpoint string) {
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", endpoint)
}
// metadataAIConfigFromServer derives the metadata translation service config
// from the server config. Used at construction and on every config reload.
func metadataAIConfigFromServer(cfg *config.Config) metadatatranslation.Config {
return metadatatranslation.Config{
Enabled: cfg.MetadataAI.Enabled,
Configured: cfg.AI.BaseURL != "",
ChatModel: cfg.AI.ChatModel,
OnView: cfg.MetadataAI.OnView,
}
}
-68
View File
@@ -2,30 +2,18 @@ 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
@@ -39,21 +27,15 @@ 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,
}
}
@@ -61,14 +43,6 @@ 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
@@ -92,48 +66,6 @@ func (p *AccountProvisioner) CreateAccount(
return user, nil
}
// defaultGroupIDs resolves the configured default group slugs (falling back to
// the built-in users group) to group IDs. Unknown slugs are skipped so a stale
// setting never blocks signups; if no configured slug resolves, the built-in
// users group is used so accounts never start with an empty policy.
func (p *AccountProvisioner) defaultGroupIDs(ctx context.Context) ([]int, error) {
slugs := []string{models.GroupSlugUsers}
if p.settings != nil {
raw, err := p.settings.Get(ctx, DefaultGroupSlugsSettingKey)
if err != nil {
slog.Warn("reading default group slugs setting failed", "key", DefaultGroupSlugsSettingKey, "error", err)
} else if strings.TrimSpace(raw) != "" {
var configured []string
if jsonErr := json.Unmarshal([]byte(raw), &configured); jsonErr != nil {
slog.Warn("parsing default group slugs setting failed", "key", DefaultGroupSlugsSettingKey, "error", jsonErr)
} else if len(configured) > 0 {
slugs = configured
}
}
}
ids := make([]int, 0, len(slugs))
for _, slug := range slugs {
group, err := p.groups.GetBySlug(ctx, slug)
if err != nil {
if errors.Is(err, ErrGroupNotFound) {
// A stale setting must not block signups.
slog.Warn("skipping unknown default group slug", "slug", slug)
continue
}
return nil, fmt.Errorf("resolving default group %q: %w", slug, err)
}
ids = append(ids, group.ID)
}
if len(ids) == 0 {
group, err := p.groups.GetBySlug(ctx, models.GroupSlugUsers)
if err != nil {
return nil, fmt.Errorf("resolving fallback group %q: %w", models.GroupSlugUsers, err)
}
ids = append(ids, group.ID)
}
return ids, nil
}
func (p *AccountProvisioner) createDefaultProfile(
ctx context.Context,
userID int,
-181
View File
@@ -3,7 +3,6 @@ package auth
import (
"context"
"errors"
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
@@ -59,8 +58,6 @@ func TestAccountProvisionerCreateAccount_SkipsProfileByDefault(t *testing.T) {
},
},
nil,
nil,
nil,
)
user, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -93,8 +90,6 @@ func TestAccountProvisionerCreateAccount_CreatesDefaultProfile(t *testing.T) {
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -130,8 +125,6 @@ func TestAccountProvisionerCreateAccount_UsesExplicitProfileName(t *testing.T) {
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -165,8 +158,6 @@ func TestAccountProvisionerCreateAccount_BlankExplicitProfileNameFallsBackToUser
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -203,8 +194,6 @@ func TestAccountProvisionerCreateAccount_DeletesUserWhenProfileCreationFails(t *
},
},
},
nil,
nil,
)
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
@@ -220,173 +209,3 @@ func TestAccountProvisionerCreateAccount_DeletesUserWhenProfileCreationFails(t *
t.Fatalf("deletedUserID = %d, want 9", deletedUserID)
}
}
type stubGroupResolver struct {
groups map[string]*models.Group
}
func (s stubGroupResolver) GetBySlug(_ context.Context, slug string) (*models.Group, error) {
if group, ok := s.groups[slug]; ok {
return group, nil
}
return nil, ErrGroupNotFound
}
type stubSettings map[string]string
func (s stubSettings) Get(_ context.Context, key string) (string, error) {
return s[key], nil
}
func TestAccountProvisionerCreateAccount_NilGroupIDsResolvesConfiguredDefaults(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["family","kids"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
"kids": {ID: 12, Slug: "kids"},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
want := []int{11, 12}
if !reflect.DeepEqual(gotInput.GroupIDs, want) {
t.Fatalf("GroupIDs = %#v, want %#v", gotInput.GroupIDs, want)
}
}
func TestAccountProvisionerCreateAccount_FallsBackToBuiltInUsersGroup(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
nil, // no settings configured
stubGroupResolver{groups: map[string]*models.Group{
models.GroupSlugUsers: {ID: 2, Slug: models.GroupSlugUsers},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{2}) {
t.Fatalf("GroupIDs = %#v, want [2]", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_ExplicitGroupIDsPassThrough(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["family"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
}},
)
explicit := []int{99}
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex", GroupIDs: explicit},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, explicit) {
t.Fatalf("GroupIDs = %#v, want %#v", gotInput.GroupIDs, explicit)
}
}
func TestAccountProvisionerCreateAccount_SkipsUnknownDefaultSlugs(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost","family"]`},
stubGroupResolver{groups: map[string]*models.Group{
"family": {ID: 11, Slug: "family"},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{11}) {
t.Fatalf("GroupIDs = %#v, want [11]", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_AllUnknownSlugsFallBackToUsersGroup(t *testing.T) {
var gotInput models.CreateUserInput
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
gotInput = input
return &models.User{ID: 1, Username: "alex"}, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost","phantom"]`},
stubGroupResolver{groups: map[string]*models.Group{
models.GroupSlugUsers: {ID: 2, Slug: models.GroupSlugUsers},
}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err != nil {
t.Fatalf("CreateAccount returned error: %v", err)
}
if !reflect.DeepEqual(gotInput.GroupIDs, []int{2}) {
t.Fatalf("GroupIDs = %#v, want [2] (built-in users fallback)", gotInput.GroupIDs)
}
}
func TestAccountProvisionerCreateAccount_FallbackGroupMissingReturnsError(t *testing.T) {
provisioner := NewAccountProvisioner(
stubAccountUsers{
createFn: func(context.Context, models.CreateUserInput) (*models.User, error) {
t.Fatal("Create should not be called when no default group resolves")
return nil, nil
},
},
nil,
stubSettings{DefaultGroupSlugsSettingKey: `["ghost"]`},
stubGroupResolver{groups: map[string]*models.Group{}},
)
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
User: models.CreateUserInput{Username: "alex"},
}); err == nil {
t.Fatal("CreateAccount returned nil error, want fallback resolution failure")
}
}
+1
View File
@@ -389,6 +389,7 @@ func (s *DeviceLoginService) Poll(ctx context.Context, deviceCode string) (*Devi
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
-113
View File
@@ -1,113 +0,0 @@
package auth
import (
"sort"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
)
// ApplyEffectivePolicy populates u's policy fields with the most-permissive
// union of the given groups. Groups are the only source of policy:
//
// - permissions: set union; "admin" implies everything
// - libraries: union; any nil (= all) or admin makes it unrestricted
// - limits: 0 means unlimited at enforcement, so any group with 0
// wins; otherwise max across groups
// - quality: most permissive; "" (unrestricted) wins
// - booleans: OR
//
// Zero groups yields the empty policy: no permissions, an empty non-nil
// library list, downloads denied. Lockout is enforced by the empty library
// list (access to no content), which makes the stream/transcode limits
// irrelevant; they stay at 0. MaxProfiles is the exception: 0 means
// unlimited at the profile-creation check, which is independent of library
// access, so it is floored at 1 to keep zero-group accounts from creating
// profiles freely. The quality ceiling is moot with no accessible content
// and is left at the lowest concrete value.
func ApplyEffectivePolicy(u *models.User, groups []models.Group) {
u.GroupIDs = make([]int, 0, len(groups))
u.IsAdmin = false
u.Permissions = []string{}
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles = 0, 0, 0
u.DownloadAllowed, u.DownloadTranscodeAllowed = false, false
u.MaxPlaybackQuality = "480p"
u.LibraryIDs = []int{}
if len(groups) == 0 {
u.MaxProfiles = 1
return
}
permSet := make(map[string]struct{})
libSet := make(map[int]struct{})
allLibraries := false
for i, g := range groups {
u.GroupIDs = append(u.GroupIDs, g.ID)
for _, p := range g.Permissions {
permSet[p] = struct{}{}
}
if g.LibraryIDs == nil {
allLibraries = true
} else {
for _, id := range g.LibraryIDs {
libSet[id] = struct{}{}
}
}
if i == 0 {
u.MaxStreams = g.MaxStreams
u.MaxTranscodes = g.MaxTranscodes
u.MaxProfiles = g.MaxProfiles
u.MaxPlaybackQuality = g.MaxPlaybackQuality
} else {
u.MaxStreams = mergeLimit(u.MaxStreams, g.MaxStreams)
u.MaxTranscodes = mergeLimit(u.MaxTranscodes, g.MaxTranscodes)
u.MaxProfiles = mergeLimit(u.MaxProfiles, g.MaxProfiles)
u.MaxPlaybackQuality = access.MaxQuality(u.MaxPlaybackQuality, g.MaxPlaybackQuality)
}
u.DownloadAllowed = u.DownloadAllowed || g.DownloadAllowed
u.DownloadTranscodeAllowed = u.DownloadTranscodeAllowed || g.DownloadTranscodeAllowed
}
// Admin implies unrestricted libraries, quality, and downloads. Stream,
// transcode, and profile limits intentionally keep the merged group
// values: before groups, admins were subject to their per-user limits
// (default 6 streams) with no bypass, and the playback session manager
// still enforces whatever it is handed.
if _, ok := permSet[string(PermissionAdmin)]; ok {
u.IsAdmin = true
allLibraries = true
u.MaxPlaybackQuality = ""
u.DownloadAllowed = true
}
u.Permissions = make([]string, 0, len(permSet))
for p := range permSet {
u.Permissions = append(u.Permissions, p)
}
sort.Strings(u.Permissions)
if allLibraries {
u.LibraryIDs = nil
} else {
u.LibraryIDs = make([]int, 0, len(libSet))
for id := range libSet {
u.LibraryIDs = append(u.LibraryIDs, id)
}
sort.Ints(u.LibraryIDs)
}
}
// mergeLimit folds group limits where 0 means unlimited at enforcement
// (max_streams, max_transcodes, max_profiles), so any unlimited group wins
// the most-permissive union; otherwise the higher cap wins.
func mergeLimit(current, next int) int {
if current == 0 || next == 0 {
return 0
}
if next > current {
return next
}
return current
}
-158
View File
@@ -1,158 +0,0 @@
package auth
import (
"reflect"
"sort"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestApplyEffectivePolicyZeroGroups(t *testing.T) {
u := &models.User{ID: 1, Enabled: true}
ApplyEffectivePolicy(u, nil)
if u.IsAdmin {
t.Error("zero groups must not grant admin")
}
if len(u.Permissions) != 0 {
t.Errorf("permissions = %v, want empty", u.Permissions)
}
if u.LibraryIDs == nil || len(u.LibraryIDs) != 0 {
t.Errorf("library ids = %v, want empty non-nil (access to none)", u.LibraryIDs)
}
// Lockout comes from the empty library list; stream/transcode limits are
// irrelevant and stay 0. MaxProfiles is floored at 1 because 0 means
// unlimited at the profile-creation check.
if u.MaxStreams != 0 || u.MaxTranscodes != 0 {
t.Errorf("stream limits = %d/%d, want 0/0", u.MaxStreams, u.MaxTranscodes)
}
if u.MaxProfiles != 1 {
t.Errorf("max profiles = %d, want 1 (0 would mean unlimited)", u.MaxProfiles)
}
if u.DownloadAllowed || u.DownloadTranscodeAllowed {
t.Error("downloads must be denied for zero groups")
}
}
func TestApplyEffectivePolicyUnion(t *testing.T) {
u := &models.User{ID: 1, Enabled: true}
ApplyEffectivePolicy(u, []models.Group{
{ID: 10, Permissions: []string{"marker_edit"}, LibraryIDs: []int{1, 2},
MaxStreams: 2, MaxTranscodes: 1, MaxProfiles: 3,
MaxPlaybackQuality: "1080p", DownloadAllowed: false},
{ID: 20, Permissions: []string{"metadata_curation"}, LibraryIDs: []int{2, 3},
MaxStreams: 4, MaxTranscodes: 2, MaxProfiles: 1,
MaxPlaybackQuality: "2160p", DownloadAllowed: true},
})
wantPerms := []string{"marker_edit", "metadata_curation"}
sort.Strings(u.Permissions)
if !reflect.DeepEqual(u.Permissions, wantPerms) {
t.Errorf("permissions = %v, want %v", u.Permissions, wantPerms)
}
wantLibs := []int{1, 2, 3}
sort.Ints(u.LibraryIDs)
if !reflect.DeepEqual(u.LibraryIDs, wantLibs) {
t.Errorf("libraries = %v, want %v", u.LibraryIDs, wantLibs)
}
if u.MaxStreams != 4 || u.MaxTranscodes != 2 || u.MaxProfiles != 3 {
t.Errorf("limits = %d/%d/%d, want 4/2/3", u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
if u.MaxPlaybackQuality != "2160p" {
t.Errorf("quality = %q, want 2160p", u.MaxPlaybackQuality)
}
if !u.DownloadAllowed {
t.Error("downloads must be OR-ed")
}
wantGroups := []int{10, 20}
sort.Ints(u.GroupIDs)
if !reflect.DeepEqual(u.GroupIDs, wantGroups) {
t.Errorf("group ids = %v, want %v", u.GroupIDs, wantGroups)
}
if u.IsAdmin {
t.Error("no group granted admin")
}
}
func TestApplyEffectivePolicyUnlimitedLimitWins(t *testing.T) {
// 0 means unlimited at enforcement for all three limits, so a group with
// 0 must win the most-permissive union over any finite cap.
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxStreams: 5, MaxTranscodes: 5, MaxProfiles: 5},
{ID: 2, MaxStreams: 0, MaxTranscodes: 0, MaxProfiles: 0},
})
if u.MaxStreams != 0 || u.MaxTranscodes != 0 || u.MaxProfiles != 0 {
t.Errorf("limits = %d/%d/%d, want 0/0/0 (unlimited wins)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
// Order must not matter: unlimited in the first group sticks.
u = &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 2, MaxStreams: 0, MaxTranscodes: 0, MaxProfiles: 0},
{ID: 1, MaxStreams: 5, MaxTranscodes: 5, MaxProfiles: 5},
})
if u.MaxStreams != 0 || u.MaxTranscodes != 0 || u.MaxProfiles != 0 {
t.Errorf("limits = %d/%d/%d, want 0/0/0 (unlimited wins regardless of order)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
}
func TestApplyEffectivePolicyFiniteLimitsTakeMax(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxStreams: 5, MaxTranscodes: 2, MaxProfiles: 4},
{ID: 2, MaxStreams: 2, MaxTranscodes: 5, MaxProfiles: 1},
})
if u.MaxStreams != 5 || u.MaxTranscodes != 5 || u.MaxProfiles != 4 {
t.Errorf("limits = %d/%d/%d, want 5/5/4 (max of finite caps)",
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
}
}
func TestApplyEffectivePolicyNilLibrariesMeansAll(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, LibraryIDs: []int{1}},
{ID: 2, LibraryIDs: nil}, // all libraries
})
if u.LibraryIDs != nil {
t.Errorf("library ids = %v, want nil (unrestricted)", u.LibraryIDs)
}
}
func TestApplyEffectivePolicyAdminShortCircuit(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, Permissions: []string{"admin"}, LibraryIDs: []int{1},
MaxStreams: 1, MaxPlaybackQuality: "480p"},
})
if !u.IsAdmin {
t.Fatal("admin permission must set IsAdmin")
}
if u.LibraryIDs != nil {
t.Error("admin must be library-unrestricted")
}
if u.MaxPlaybackQuality != "" {
t.Error("admin must be quality-unrestricted")
}
// Admin does not bypass stream/transcode/profile limits (matching the
// pre-group behavior where admins were subject to per-user limits); the
// merged group values stand.
if u.MaxStreams != 1 {
t.Errorf("max streams = %d, want 1 (admin keeps merged group limits)", u.MaxStreams)
}
}
func TestApplyEffectivePolicyQualityUnrestrictedWins(t *testing.T) {
u := &models.User{}
ApplyEffectivePolicy(u, []models.Group{
{ID: 1, MaxPlaybackQuality: "1080p"},
{ID: 2, MaxPlaybackQuality: ""}, // unrestricted
})
if u.MaxPlaybackQuality != "" {
t.Errorf("quality = %q, want unrestricted", u.MaxPlaybackQuality)
}
}
-604
View File
@@ -1,604 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/text/unicode/norm"
"github.com/Silo-Server/silo-server/internal/models"
)
// Sentinel errors for group operations.
var (
ErrGroupNotFound = errors.New("group not found")
ErrBuiltInGroup = errors.New("built-in group cannot be deleted")
ErrAdminPermRequired = errors.New("administrators group must keep the admin permission")
ErrLastAdministrator = errors.New("cannot remove the last enabled administrator")
ErrUnknownGroup = errors.New("unknown group")
ErrUnknownUser = errors.New("unknown user")
)
// GroupRepository provides CRUD and membership operations for groups.
// All mutations bump access_policy_revision for affected users set-based so
// the access resolver invalidates stale scopes.
type GroupRepository struct {
pool *pgxpool.Pool
}
func NewGroupRepository(pool *pgxpool.Pool) *GroupRepository {
return &GroupRepository{pool: pool}
}
// groupColumns is table-qualified so it stays unambiguous in queries that
// join user_groups (which also has a created_at column). Qualified names are
// valid in plain SELECTs and in INSERT/UPDATE ... RETURNING alike.
const groupColumns = `groups.id, groups.slug, groups.name, groups.description,
groups.built_in, groups.permissions, groups.library_ids, groups.max_streams,
groups.max_transcodes, groups.max_profiles, groups.max_playback_quality,
groups.download_allowed, groups.download_transcode_allowed,
groups.created_at, groups.updated_at`
func scanGroupFields(row pgx.Row, g *models.Group, extra ...any) error {
dest := []any{
&g.ID, &g.Slug, &g.Name, &g.Description, &g.BuiltIn, &g.Permissions,
&g.LibraryIDs, &g.MaxStreams, &g.MaxTranscodes, &g.MaxProfiles,
&g.MaxPlaybackQuality, &g.DownloadAllowed, &g.DownloadTranscodeAllowed,
&g.CreatedAt, &g.UpdatedAt,
}
dest = append(dest, extra...)
return row.Scan(dest...)
}
func scanGroup(row pgx.Row) (*models.Group, error) {
var g models.Group
if err := scanGroupFields(row, &g); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrGroupNotFound
}
return nil, fmt.Errorf("scanning group: %w", err)
}
return &g, nil
}
var slugStripRe = regexp.MustCompile(`[^a-z0-9]+`)
// slugifyGroupName derives a stable slug from a display name.
// Accented characters are decomposed via NFD and their combining marks are
// stripped so that e.g. "ï" → "i" before the ASCII-only filter is applied.
func slugifyGroupName(name string) string {
// NFD-decompose so accented letters split into base + combining mark.
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(name)))
// Drop combining (diacritic) runes; keep everything else for the regex.
var b strings.Builder
for _, r := range decomposed {
if unicode.Is(unicode.Mn, r) {
continue
}
b.WriteRune(r)
}
s := slugStripRe.ReplaceAllString(b.String(), "-")
return strings.Trim(s, "-")
}
// GroupWithMemberCount pairs a group with its member count for list views.
type GroupWithMemberCount struct {
models.Group
MemberCount int
}
// List returns all groups with member counts, built-ins first.
func (r *GroupRepository) List(ctx context.Context) ([]GroupWithMemberCount, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`,
(SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = groups.id) AS member_count
FROM groups
ORDER BY built_in DESC, name ASC`)
if err != nil {
return nil, fmt.Errorf("listing groups: %w", err)
}
defer rows.Close()
var out []GroupWithMemberCount
for rows.Next() {
var g GroupWithMemberCount
if err := scanGroupFields(rows, &g.Group, &g.MemberCount); err != nil {
return nil, fmt.Errorf("scanning group row: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
func (r *GroupRepository) GetByID(ctx context.Context, id int) (*models.Group, error) {
return scanGroup(r.pool.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1`, id))
}
func (r *GroupRepository) GetBySlug(ctx context.Context, slug string) (*models.Group, error) {
return scanGroup(r.pool.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE slug = $1`, slug))
}
// GroupsForUser returns the groups a user belongs to.
func (r *GroupRepository) GroupsForUser(ctx context.Context, userID int) ([]models.Group, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`
FROM groups
JOIN user_groups ug ON ug.group_id = groups.id
WHERE ug.user_id = $1
ORDER BY groups.id`, userID)
if err != nil {
return nil, fmt.Errorf("loading user groups: %w", err)
}
defer rows.Close()
var out []models.Group
for rows.Next() {
var g models.Group
if err := scanGroupFields(rows, &g); err != nil {
return nil, fmt.Errorf("scanning group row: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// GroupsForUsers returns group memberships for many users in one query.
func (r *GroupRepository) GroupsForUsers(ctx context.Context, userIDs []int) (map[int][]models.Group, error) {
out := make(map[int][]models.Group, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
rows, err := r.pool.Query(ctx, `
SELECT `+groupColumns+`, ug.user_id
FROM groups
JOIN user_groups ug ON ug.group_id = groups.id
WHERE ug.user_id = ANY($1::int[])
ORDER BY groups.id`, userIDs)
if err != nil {
return nil, fmt.Errorf("loading users' groups: %w", err)
}
defer rows.Close()
for rows.Next() {
var userID int
var g models.Group
if err := scanGroupFields(rows, &g, &userID); err != nil {
return nil, fmt.Errorf("scanning user group row: %w", err)
}
out[userID] = append(out[userID], g)
}
return out, rows.Err()
}
// Create inserts a new (non-built-in) group.
func (r *GroupRepository) Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error) {
permissions, err := NormalizePermissions(input.Permissions)
if err != nil {
return nil, err
}
slug := slugifyGroupName(input.Name)
if slug == "" {
return nil, fmt.Errorf("group name must contain at least one alphanumeric character")
}
cols := []string{"slug", "name", "description", "permissions", "library_ids"}
args := []any{slug, strings.TrimSpace(input.Name), input.Description, permissions, input.LibraryIDs}
type optionalCol struct {
col string
val any
set bool
}
optional := []optionalCol{
{"max_streams", derefAny(input.MaxStreams), input.MaxStreams != nil},
{"max_transcodes", derefAny(input.MaxTranscodes), input.MaxTranscodes != nil},
{"max_profiles", derefAny(input.MaxProfiles), input.MaxProfiles != nil},
{"max_playback_quality", derefAny(input.MaxPlaybackQuality), input.MaxPlaybackQuality != nil},
{"download_allowed", derefAny(input.DownloadAllowed), input.DownloadAllowed != nil},
{"download_transcode_allowed", derefAny(input.DownloadTranscodeAllowed), input.DownloadTranscodeAllowed != nil},
}
for _, o := range optional {
if o.set {
cols = append(cols, o.col)
args = append(args, o.val)
}
}
placeholders := make([]string, len(args))
for i := range args {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf("INSERT INTO groups (%s) VALUES (%s) RETURNING %s",
strings.Join(cols, ", "), strings.Join(placeholders, ", "), groupColumns)
group, err := scanGroup(r.pool.QueryRow(ctx, query, args...))
if err != nil {
if isDuplicateKeyError(err) {
return nil, fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
}
return nil, fmt.Errorf("creating group: %w", err)
}
return group, nil
}
func derefAny[T any](p *T) any {
if p == nil {
return nil
}
return *p
}
// Update modifies a group and bumps all members' access_policy_revision when
// a policy field changes. The administrators group cannot lose the admin
// permission.
func (r *GroupRepository) Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("beginning group update: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Lock the group row: serializes concurrent policy/membership mutations.
current, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, id))
if err != nil {
return nil, err
}
var normalizedPerms []string
if input.Permissions != nil {
var err error
normalizedPerms, err = NormalizePermissions(*input.Permissions)
if err != nil {
return nil, err
}
if current.Slug == models.GroupSlugAdministrators {
hasAdmin := false
for _, p := range normalizedPerms {
if p == string(PermissionAdmin) {
hasAdmin = true
break
}
}
if !hasAdmin {
return nil, ErrAdminPermRequired
}
}
}
setClauses := []string{}
policyChanged := false
args := []any{}
idx := 1
add := func(col string, val any, policy bool) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
if policy {
policyChanged = true
}
}
if input.Name != nil {
add("name", strings.TrimSpace(*input.Name), false)
}
if input.Description != nil {
add("description", *input.Description, false)
}
if input.Permissions != nil {
add("permissions", normalizedPerms, true)
}
if input.LibraryIDs != nil {
add("library_ids", *input.LibraryIDs, true)
}
if input.MaxStreams != nil {
add("max_streams", *input.MaxStreams, true)
}
if input.MaxTranscodes != nil {
add("max_transcodes", *input.MaxTranscodes, true)
}
if input.MaxProfiles != nil {
add("max_profiles", *input.MaxProfiles, true)
}
if input.MaxPlaybackQuality != nil {
add("max_playback_quality", *input.MaxPlaybackQuality, true)
}
if input.DownloadAllowed != nil {
add("download_allowed", *input.DownloadAllowed, true)
}
if input.DownloadTranscodeAllowed != nil {
add("download_transcode_allowed", *input.DownloadTranscodeAllowed, true)
}
if len(setClauses) == 0 {
return current, nil
}
setClauses = append(setClauses, "updated_at = NOW()")
args = append(args, id)
query := fmt.Sprintf("UPDATE groups SET %s WHERE id = $%d RETURNING %s",
strings.Join(setClauses, ", "), idx, groupColumns)
updated, err := scanGroup(tx.QueryRow(ctx, query, args...))
if err != nil {
return nil, fmt.Errorf("updating group: %w", err)
}
if policyChanged {
if err := bumpGroupMemberRevisions(ctx, tx, id); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("committing group update: %w", err)
}
return updated, nil
}
// bumpGroupMemberRevisions invalidates all members' resolved scopes in one
// set-based statement (never loop per user — groups can have thousands of
// members).
func bumpGroupMemberRevisions(ctx context.Context, tx pgx.Tx, groupID int) error {
_, err := tx.Exec(ctx, `
UPDATE users u
SET access_policy_revision = access_policy_revision + 1
FROM user_groups ug
WHERE ug.user_id = u.id AND ug.group_id = $1`, groupID)
if err != nil {
return fmt.Errorf("bumping member policy revisions: %w", err)
}
return nil
}
// Delete removes a non-built-in group. Member revisions are bumped before the
// memberships cascade away.
func (r *GroupRepository) Delete(ctx context.Context, id int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning group delete: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
group, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, id))
if err != nil {
return err
}
if group.BuiltIn {
return ErrBuiltInGroup
}
if err := bumpGroupMemberRevisions(ctx, tx, id); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM groups WHERE id = $1`, id); err != nil {
return fmt.Errorf("deleting group: %w", err)
}
return tx.Commit(ctx)
}
// MemberCount returns the number of users in a group.
func (r *GroupRepository) MemberCount(ctx context.Context, groupID int) (int, error) {
var count int
if err := r.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM user_groups WHERE group_id = $1`, groupID).Scan(&count); err != nil {
return 0, fmt.Errorf("counting group members: %w", err)
}
return count, nil
}
// GroupMember is one row of a paginated member listing.
type GroupMember struct {
UserID int
Username string
Email string
Enabled bool
}
// ListMembers returns one page of a group's members plus the total count.
func (r *GroupRepository) ListMembers(ctx context.Context, groupID, offset, limit int) ([]GroupMember, int, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
total, err := r.MemberCount(ctx, groupID)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT u.id, u.username, u.email, u.enabled
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1
ORDER BY u.username
OFFSET $2 LIMIT $3`, groupID, offset, limit)
if err != nil {
return nil, 0, fmt.Errorf("listing members: %w", err)
}
defer rows.Close()
var members []GroupMember
for rows.Next() {
var m GroupMember
if err := rows.Scan(&m.UserID, &m.Username, &m.Email, &m.Enabled); err != nil {
return nil, 0, fmt.Errorf("scanning member: %w", err)
}
members = append(members, m)
}
return members, total, rows.Err()
}
// AddMember adds a user to a group (idempotent) and bumps their revision.
func (r *GroupRepository) AddMember(ctx context.Context, groupID, userID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning add member: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, groupID)); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, userID, groupID)
if err != nil {
if isForeignKeyError(err) {
return ErrUnknownUser
}
return fmt.Errorf("adding member: %w", err)
}
if tag.RowsAffected() > 0 {
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping member revision: %w", err)
}
}
return tx.Commit(ctx)
}
// lastEnabledAdministratorGuard returns ErrLastAdministrator when userID is an
// enabled member of the administrators group and no other enabled member
// exists, i.e. when removing, disabling, or deleting userID would leave the
// server without an enabled administrator. It locks the administrators group
// row (FOR UPDATE) so concurrent membership/disable/delete mutations
// serialize; re-locking the same row later in the same transaction is a no-op.
func lastEnabledAdministratorGuard(ctx context.Context, tx pgx.Tx, excludingUserID int) error {
var adminGroupID int
if err := tx.QueryRow(ctx, `
SELECT id FROM groups WHERE slug = $1 FOR UPDATE`,
models.GroupSlugAdministrators).Scan(&adminGroupID); err != nil {
return fmt.Errorf("locking administrators group: %w", err)
}
// Only an enabled administrator can be the last enabled administrator;
// mutating a non-member or disabled member never breaks the invariant.
var enabledMember bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1 AND ug.user_id = $2 AND u.enabled)`,
adminGroupID, excludingUserID).Scan(&enabledMember); err != nil {
return fmt.Errorf("checking administrators membership: %w", err)
}
if !enabledMember {
return nil
}
var remaining int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1 AND u.enabled AND ug.user_id <> $2`,
adminGroupID, excludingUserID).Scan(&remaining); err != nil {
return fmt.Errorf("counting administrators: %w", err)
}
if remaining == 0 {
return ErrLastAdministrator
}
return nil
}
// RemoveMember removes a user from a group. Removing the last enabled member
// of the administrators group is rejected.
func (r *GroupRepository) RemoveMember(ctx context.Context, groupID, userID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning remove member: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
group, err := scanGroup(tx.QueryRow(ctx,
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, groupID))
if err != nil {
return err
}
if group.Slug == models.GroupSlugAdministrators {
if err := lastEnabledAdministratorGuard(ctx, tx, userID); err != nil {
return err
}
}
tag, err := tx.Exec(ctx,
`DELETE FROM user_groups WHERE group_id = $1 AND user_id = $2`, groupID, userID)
if err != nil {
return fmt.Errorf("removing member: %w", err)
}
if tag.RowsAffected() > 0 {
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping member revision: %w", err)
}
}
return tx.Commit(ctx)
}
// ReplaceUserGroups sets a user's memberships to exactly groupIDs, applying
// the last-administrator guard if administrators membership is being removed.
func (r *GroupRepository) ReplaceUserGroups(ctx context.Context, userID int, groupIDs []int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning group replacement: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := r.replaceUserGroupsTx(ctx, tx, userID, groupIDs); err != nil {
return err
}
return tx.Commit(ctx)
}
// replaceUserGroupsTx performs the membership replacement inside the caller's
// transaction so it can be composed with other writes atomically.
func (r *GroupRepository) replaceUserGroupsTx(ctx context.Context, tx pgx.Tx, userID int, groupIDs []int) error {
// Lock the administrators group row so concurrent removals serialize.
var adminGroupID int
if err := tx.QueryRow(ctx, `
SELECT id FROM groups WHERE slug = $1 FOR UPDATE`,
models.GroupSlugAdministrators).Scan(&adminGroupID); err != nil {
return fmt.Errorf("locking administrators group: %w", err)
}
keepingAdmin := false
for _, id := range groupIDs {
if id == adminGroupID {
keepingAdmin = true
break
}
}
if !keepingAdmin {
if err := lastEnabledAdministratorGuard(ctx, tx, userID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `DELETE FROM user_groups WHERE user_id = $1`, userID); err != nil {
return fmt.Errorf("clearing memberships: %w", err)
}
if len(groupIDs) > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO user_groups (user_id, group_id)
SELECT $1, unnest($2::int[])
ON CONFLICT DO NOTHING`, userID, groupIDs); err != nil {
if isForeignKeyError(err) {
return fmt.Errorf("%w", ErrUnknownGroup)
}
return fmt.Errorf("adding memberships: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE users SET access_policy_revision = access_policy_revision + 1
WHERE id = $1`, userID); err != nil {
return fmt.Errorf("bumping user revision: %w", err)
}
return nil
}
-18
View File
@@ -1,18 +0,0 @@
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)
}
}
}
+27 -14
View File
@@ -3,6 +3,7 @@ package auth
import (
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -17,6 +18,7 @@ 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"`
@@ -35,65 +37,76 @@ 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 time.Duration
refreshExpiry time.Duration
accessExpiry atomic.Int64 // nanoseconds
refreshExpiry atomic.Int64 // nanoseconds
}
// NewJWTService creates a new JWTService with the given secret and expiry durations.
func NewJWTService(secret string, accessExpiry, refreshExpiry time.Duration) *JWTService {
return &JWTService{
secret: []byte(secret),
accessExpiry: accessExpiry,
refreshExpiry: refreshExpiry,
}
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))
}
// AccessExpiry returns the configured access token expiry duration.
func (j *JWTService) AccessExpiry() time.Duration {
return j.accessExpiry
return time.Duration(j.accessExpiry.Load())
}
// RefreshExpiry returns the configured refresh token expiry duration.
func (j *JWTService) RefreshExpiry() time.Duration {
return j.refreshExpiry
return time.Duration(j.refreshExpiry.Load())
}
// GenerateAccessToken creates a signed JWT access token with the configured
// access token expiry duration.
func (j *JWTService) GenerateAccessToken(userID int, sessionID string) (string, error) {
func (j *JWTService) GenerateAccessToken(userID int, role, 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, sessionID string) (string, error) {
func (j *JWTService) GenerateRefreshToken(userID int, role, sessionID string) (string, error) {
return j.generateRefreshToken(Claims{
UserID: userID,
Role: role,
SessionID: sessionID,
})
}
func (j *JWTService) GeneratePluginAccessToken(userID int, sessionID string, ttl time.Duration) (string, error) {
func (j *JWTService) GeneratePluginAccessToken(userID int, role, 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.
+20 -19
View File
@@ -18,7 +18,7 @@ func newTestJWTService() *auth.JWTService {
func TestJWT_GenerateAccessToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateAccessToken(42, "sess-abc-123")
token, err := svc.GenerateAccessToken(42, "admin", "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, "sess-def-456")
token, err := svc.GenerateRefreshToken(42, "user", "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, "sess-uuid-1")
token, err := svc.GenerateAccessToken(1, "user", "sess-uuid-1")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -61,6 +61,9 @@ 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")
}
@@ -88,7 +91,7 @@ func TestJWT_ValidateAccessToken(t *testing.T) {
func TestJWT_ValidateRefreshToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateRefreshToken(99, "sess-uuid-2")
token, err := svc.GenerateRefreshToken(99, "admin", "sess-uuid-2")
if err != nil {
t.Fatalf("GenerateRefreshToken() error: %v", err)
}
@@ -101,6 +104,9 @@ 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")
}
@@ -112,12 +118,12 @@ func TestJWT_ValidateRefreshToken(t *testing.T) {
func TestJWT_AccessTokenExpiry(t *testing.T) {
svc := newTestJWTService()
accessToken, err := svc.GenerateAccessToken(1, "sess-1")
accessToken, err := svc.GenerateAccessToken(1, "user", "sess-1")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
refreshToken, err := svc.GenerateRefreshToken(1, "sess-1")
refreshToken, err := svc.GenerateRefreshToken(1, "user", "sess-1")
if err != nil {
t.Fatalf("GenerateRefreshToken() error: %v", err)
}
@@ -156,7 +162,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, "sess-expired")
token, err := svc.GenerateAccessToken(1, "user", "sess-expired")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -170,19 +176,13 @@ func TestJWT_ExpiredToken(t *testing.T) {
func TestJWT_TamperedToken(t *testing.T) {
svc := newTestJWTService()
token, err := svc.GenerateAccessToken(1, "sess-tamper")
token, err := svc.GenerateAccessToken(1, "user", "sess-tamper")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
// 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
// Tamper with the token by modifying the last character of the signature.
tampered := token[:len(token)-1] + "X"
_, 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, "sess-wrong-secret")
token, err := svc1.GenerateAccessToken(1, "user", "sess-wrong-secret")
if err != nil {
t.Fatalf("GenerateAccessToken() error: %v", err)
}
@@ -210,6 +210,7 @@ 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(),
@@ -249,12 +250,12 @@ func TestJWT_GarbageToken(t *testing.T) {
func TestJWT_DifferentUsersGetDifferentTokens(t *testing.T) {
svc := newTestJWTService()
token1, err := svc.GenerateAccessToken(1, "sess-1")
token1, err := svc.GenerateAccessToken(1, "user", "sess-1")
if err != nil {
t.Fatalf("GenerateAccessToken(1) error: %v", err)
}
token2, err := svc.GenerateAccessToken(2, "sess-2")
token2, err := svc.GenerateAccessToken(2, "admin", "sess-2")
if err != nil {
t.Fatalf("GenerateAccessToken(2) error: %v", err)
}
+6 -4
View File
@@ -11,13 +11,11 @@ import (
type Permission string
const (
PermissionAdmin Permission = "admin"
PermissionMarkerEdit Permission = "marker_edit"
PermissionMetadataCuration Permission = "metadata_curation"
)
var assignablePermissions = map[Permission]struct{}{
PermissionAdmin: {},
PermissionMarkerEdit: {},
PermissionMetadataCuration: {},
}
@@ -62,6 +60,10 @@ 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
@@ -78,7 +80,7 @@ func HasEffectivePermission(user *models.User, permission Permission) bool {
if user == nil || !user.Enabled {
return false
}
if user.IsAdmin {
if user.Role == "admin" {
return isAssignablePermission(permission)
}
return HasAssignedPermission(user, permission)
@@ -88,7 +90,7 @@ func EffectivePermissions(user *models.User) []string {
if user == nil || !user.Enabled {
return []string{}
}
if user.IsAdmin {
if user.Role == "admin" {
return assignablePermissionList()
}
permissions, err := NormalizePermissions(user.Permissions)
+7 -19
View File
@@ -30,7 +30,7 @@ func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) {
}
func TestHasEffectivePermission_AdminImpliesAssignablePermissions(t *testing.T) {
user := &models.User{IsAdmin: true, Enabled: true}
user := &models.User{Role: "admin", 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{Enabled: true}
user := &models.User{Role: "user", Enabled: true}
if HasEffectivePermission(user, PermissionMetadataCuration) {
t.Fatal("plain user should not have metadata curation")
}
@@ -50,22 +50,10 @@ func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
}
}
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")
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)
}
}
+2 -1
View File
@@ -54,7 +54,7 @@ func NewPluginProviderWithClientFactory(
sessions: sessions,
users: users,
identityPool: pool,
accounts: NewAccountProvisioner(users, nil, nil, groupResolverFor(users)),
accounts: NewAccountProvisioner(users, nil),
}
}
@@ -264,6 +264,7 @@ func (p *PluginProvider) autoProvisionUser(
Username: username,
Password: password,
LocalPasswordLoginEnabled: &localPasswordLoginEnabled,
Role: "user",
},
})
if err == nil {
+186 -195
View File
@@ -30,11 +30,6 @@ 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 {
@@ -42,25 +37,22 @@ func CheckPassword(user *models.User, password string) bool {
return err == nil
}
// UserRepository provides CRUD operations for the users table. Effective
// access policy is derived from group memberships when users are loaded.
// UserRepository provides CRUD operations for the users table.
type UserRepository struct {
pool *pgxpool.Pool
groups *GroupRepository
pool *pgxpool.Pool
}
// NewUserRepository creates a new UserRepository backed by the given pool.
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool, groups: NewGroupRepository(pool)}
return &UserRepository{pool: 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, enabled,
access_policy_revision, created_at, updated_at`
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`
// scanUser scans a single row into a *models.User.
func scanUser(row pgx.Row) (*models.User, error) {
@@ -71,8 +63,17 @@ 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,
)
@@ -89,11 +90,31 @@ func scanUser(row pgx.Row) (*models.User, error) {
func scanUsers(rows pgx.Rows) ([]*models.User, error) {
var users []*models.User
for rows.Next() {
u, err := scanUser(rows)
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,
)
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)
@@ -101,46 +122,77 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) {
return users, nil
}
// 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.
// Create inserts a new user with a bcrypt-hashed password and returns the created user.
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
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("beginning user create: %w", err)
permissions := append([]string(nil), input.Permissions...)
if input.Permissions == nil && input.Role != "admin" {
permissions = DefaultUserPermissions()
}
permissions, err = NormalizePermissions(permissions)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
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`,
cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"}
args := []any{
NormalizeEmail(input.Email),
NormalizeUsername(input.Username),
string(hash),
localPasswordLoginEnabled,
).Scan(&id)
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)
if err != nil {
if isDuplicateKeyError(err) {
return nil, fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
@@ -148,67 +200,29 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu
return nil, fmt.Errorf("creating user: %w", err)
}
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)
return user, nil
}
// 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`
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
return scanUser(r.pool.QueryRow(ctx, query, id))
}
// 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`
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
return scanUser(r.pool.QueryRow(ctx, query, NormalizeUsername(username)))
}
// 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`
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
return scanUser(r.pool.QueryRow(ctx, query, NormalizeEmail(email)))
}
// 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. A
// non-nil GroupIDs replaces the user's group memberships.
// If the input contains a Password, it is bcrypt-hashed before storage.
func (r *UserRepository) Update(ctx context.Context, id int, input models.UpdateUserInput) error {
setClauses := []string{}
accessPolicyPredicates := []string{}
@@ -239,107 +253,113 @@ 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++
}
// 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.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++
}
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 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++
}
if len(setClauses) == 0 {
// 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
}
}
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. 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 {
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 {
// Nothing to update; still verify the user exists.
_, err := r.GetByID(ctx, id)
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 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))
}
return fmt.Errorf("updating 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
}
// Delete removes a user by their ID.
func (r *UserRepository) Delete(ctx context.Context, id int) error {
tag, err := r.pool.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
}
return nil
}
@@ -352,27 +372,7 @@ func (r *UserRepository) List(ctx context.Context) ([]*models.User, error) {
}
defer rows.Close()
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
return scanUsers(rows)
}
// Count returns the number of users in the database.
@@ -393,15 +393,6 @@ 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
+11 -43
View File
@@ -35,33 +35,6 @@ 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.
@@ -127,7 +100,7 @@ func NewService(
settings: settings,
providers: map[string]AuthProvider{},
metadata: map[string]LoginProviderInfo{},
accounts: NewAccountProvisioner(users, storeProvider, settings, groupResolverFor(users)),
accounts: NewAccountProvisioner(users, storeProvider),
}
if provider != nil {
service.RegisterProvider(LoginProviderInfo{
@@ -236,6 +209,7 @@ 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 {
@@ -297,6 +271,7 @@ func (s *Service) loginWithProvider(
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
@@ -331,20 +306,12 @@ 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,
// 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},
Role: "admin",
},
DefaultProfile: DefaultProfileOptions{
Enabled: createDefaultProfile,
@@ -355,7 +322,6 @@ 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)
}
@@ -386,13 +352,13 @@ func (s *Service) Signup(
return nil, nil, err
}
// Create the user; nil GroupIDs lets the provisioner resolve the
// configured default groups.
// Create the user with standard role and access to all libraries.
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
User: models.CreateUserInput{
Username: username,
Email: email,
Password: password,
Role: "user",
},
DefaultProfile: DefaultProfileOptions{
Enabled: createDefaultProfile,
@@ -446,7 +412,7 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
}
return nil, nil, nil, fmt.Errorf("getting admin user: %w", err)
}
if !admin.IsAdmin || !admin.Enabled {
if admin.Role != "admin" || !admin.Enabled {
return nil, nil, nil, ErrImpersonationNotAllowed
}
if adminUserID == targetUserID {
@@ -457,7 +423,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.IsAdmin {
if !target.Enabled || target.Role == "admin" {
return nil, nil, nil, ErrImpersonationNotAllowed
}
@@ -480,6 +446,7 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
pair, err := s.generateTokenPair(Claims{
UserID: target.ID,
Role: target.Role,
SessionID: sessionID,
ImpersonatorUserID: &impersonatorUserID,
})
@@ -552,6 +519,7 @@ 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,
})
@@ -569,7 +537,7 @@ func (s *Service) validateImpersonator(ctx context.Context, impersonatorUserID *
}
return fmt.Errorf("getting impersonator user: %w", err)
}
if !impersonator.Enabled || !impersonator.IsAdmin {
if !impersonator.Enabled || impersonator.Role != "admin" {
return ErrSessionRevoked
}
return nil
@@ -91,6 +91,16 @@ 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
+5 -13
View File
@@ -127,16 +127,15 @@ type userDBConfigRaw struct {
// ScannerConfig holds media scanner settings.
type ScannerConfig struct {
FileRemovalGrace time.Duration `yaml:"-"`
Workers int `yaml:"workers"`
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
EmptyTrashAfterScan bool `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"`
FileRemovalGrace string `yaml:"file_removal_grace"` // legacy; preserved on YAML import only
Workers int `yaml:"workers"`
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
@@ -165,9 +164,6 @@ 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.
@@ -414,7 +410,6 @@ func setDefaults() *configRaw {
StaleGraceSeconds: 120,
},
Scanner: scannerConfigRaw{
FileRemovalGrace: "24h",
Workers: 8,
MaxConcurrentLibraries: 1,
MaxConcurrentScoped: 2,
@@ -432,9 +427,6 @@ func setDefaults() *configRaw {
ChapterThumbnailExecution: "local",
ChapterThumbnailNodeCapacity: 1,
TranscodeEnabled: true,
AllowHEVCEncoding: false,
TranscodeAheadSegments: 30,
SegmentDuration: 6,
},
RateLimit: RateLimitConfig{
Enabled: true,
-20
View File
@@ -253,11 +253,6 @@ 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
@@ -315,21 +310,6 @@ func LoadFromDB(m map[string]string) (*Config, error) {
return nil, err
}
cfg.Playback.TranscodeEnabled = transcodeEnabled
allowHEVC, err := boolOr(m, "playback.allow_hevc_encoding", false)
if err != nil {
return nil, err
}
cfg.Playback.AllowHEVCEncoding = allowHEVC
transcodeAheadSegments, err := intOr(m, "playback.transcode_ahead_segments", 30)
if err != nil {
return nil, err
}
cfg.Playback.TranscodeAheadSegments = transcodeAheadSegments
segmentDuration, err := intOr(m, "playback.segment_duration", 6)
if err != nil {
return nil, err
}
cfg.Playback.SegmentDuration = segmentDuration
// Redis
cfg.Redis.URL = stringOr(m, "redis.url", "")
+98
View File
@@ -0,0 +1,98 @@
package config
import "strings"
// restartRequiredKeys lists server_settings keys whose values are captured at
// process startup (listeners, connection pools, HTTP clients, worker pools)
// and cannot take effect until the server restarts.
//
// Keys absent from this map and from restartRequiredPrefixes apply without a
// restart: they are either read live from the settings repo at request time
// (overlays, branding, markers, download.*, ...) or hot-reloaded through the
// nodeconfig watcher. When converting a frozen consumer to the live config,
// remove its key here — the admin UI restart banner is driven by this
// registry.
var restartRequiredKeys = map[string]bool{
// Process & logging. log_level/log_quiet hot-reload via the config
// watcher (shared slog.LevelVar + logfilter.SetQuiet).
"server.listen": true,
"server.mode": true,
"server.log_format": true,
"opslog.capture_level": true,
// Auth. The JWT secret is baked into token services and stream signers;
// the expiry durations hot-reload (new tokens only).
"auth.jwt_secret": true,
// Rate limiting gates middleware construction; tier/limit values inside
// the dedicated /admin/rate-limits endpoint hot-reload and report their
// own restart state.
"ratelimit.enabled": true,
"ratelimit.backend": true,
// Playback transcode infrastructure. The playback/stream handlers read
// ffmpeg path and hwaccel live (new transcode sessions), but several
// startup-built consumers still freeze them (scanner ffprobe, chapter
// thumbnails, audiobook enricher) — keep restart-required until those
// convert. transcode_dir is fully live (only the playback handler reads
// it). The chapter-thumbnail worker pool is sized at construction.
"playback.ffmpeg_path": true,
"playback.hw_accel": true,
"playback.hw_device": true,
"playback.chapter_thumbnail_workers": true,
// Scanner / matcher toggles captured at construction. Worker counts,
// batch size, metadata.cache_images, and mdblist.api_key hot-reload via
// atomic setters wired to the config watcher.
"scanner.max_concurrent_libraries": true,
"scanner.max_concurrent_scoped": true,
"scanner.empty_trash_after_scan": true,
"matcher.enable_tv_series_root_queue": true,
"matcher.enable_tv_series_group_queue": true,
// External API clients built once at startup.
"tmdb.api_key": true,
// Compat listeners and session stores.
"audiobookshelf_compat.enabled": true,
// public_url / server_name / emulated_server_version are read live per
// request; server_id is generate-once and baked into the resource
// mapper; the session TTLs are baked into store constructors.
"jellyfin_compat.listen": true,
"jellyfin_compat.server_id": true,
"jellyfin_compat.web_version": true,
"jellyfin_compat.web_dir": true,
"jellyfin_compat.session_ttl": true,
"jellyfin_compat.playback_session_ttl": true,
// AI: connection settings, models, toggles, and quotas hot-reload via
// UpdateConfig/setters wired to the config watcher. Only the dispatch
// semaphore (ai.max_concurrent_jobs, plus its legacy alias) is a
// fixed-capacity channel sized at construction.
"ai.max_concurrent_jobs": true,
"subtitle_ai.max_concurrent_jobs": true,
}
// restartRequiredPrefixes covers whole namespaces of infrastructure settings:
// connection pools and storage clients that are constructed once at startup.
var restartRequiredPrefixes = []string{
"database.",
"userdb.",
"s3.",
"redis.",
"recommendations.",
}
// RestartRequired reports whether changing the given server_settings key
// requires a server restart to take effect.
func RestartRequired(key string) bool {
if restartRequiredKeys[key] {
return true
}
for _, prefix := range restartRequiredPrefixes {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}
+41
View File
@@ -0,0 +1,41 @@
package config
import "testing"
func TestRestartRequired(t *testing.T) {
cases := []struct {
key string
want bool
}{
// Explicit infrastructure keys.
{"server.listen", true},
{"auth.jwt_secret", true},
{"ratelimit.backend", true},
// Prefix-covered namespaces.
{"database.max_connections", true},
{"userdb.backend", true},
{"s3.public_endpoint", true},
{"redis.url", true},
{"recommendations.enabled", true},
// Legacy AI aliases classify like their canonical keys.
{"subtitle_ai.max_concurrent_jobs", true},
{"subtitle_ai.base_url", false}, // like ai.base_url — hot-reloaded
{"ai.api_key", false},
{"subtitle_ai.enabled", false},
{"metadata_ai.on_view", false},
// Live settings read from the settings repo per request.
{"branding.server_name", false},
{"overlays.enabled", false},
{"markers.mode", false},
{"download.enabled", false},
{"allow_4k_transcode", false},
{"defaults.card_overlays", false},
// Unknown keys default to live.
{"some.future_setting", false},
}
for _, tc := range cases {
if got := RestartRequired(tc.key); got != tc.want {
t.Errorf("RestartRequired(%q) = %v, want %v", tc.key, got, tc.want)
}
}
}
-7
View File
@@ -181,13 +181,6 @@ func YAMLToSettingsMap(path string) (map[string]string, error) {
m["playback.chapter_thumbnail_node_capacity"] = strconv.Itoa(raw.Playback.ChapterThumbnailNodeCapacity)
}
m["playback.transcode_enabled"] = strconv.FormatBool(raw.Playback.TranscodeEnabled)
m["playback.allow_hevc_encoding"] = strconv.FormatBool(raw.Playback.AllowHEVCEncoding)
if raw.Playback.TranscodeAheadSegments != 0 {
m["playback.transcode_ahead_segments"] = strconv.Itoa(raw.Playback.TranscodeAheadSegments)
}
if raw.Playback.SegmentDuration != 0 {
m["playback.segment_duration"] = strconv.Itoa(raw.Playback.SegmentDuration)
}
// Redis
m["redis.url"] = raw.Redis.URL
+4
View File
@@ -16,6 +16,8 @@ const (
ChannelHistoryImport EventChannel = "history_import"
ChannelUserState EventChannel = "user_state"
ChannelPlugins EventChannel = "plugins"
ChannelNotifications EventChannel = "notifications"
ChannelRequests EventChannel = "requests"
)
var AllChannels = []EventChannel{
@@ -27,6 +29,8 @@ var AllChannels = []EventChannel{
ChannelHistoryImport,
ChannelUserState,
ChannelPlugins,
ChannelNotifications,
ChannelRequests,
}
type Envelope struct {
+4 -4
View File
@@ -15,10 +15,10 @@ type ScopeResolver interface {
}
// NewScopeAccessFilter returns an AccessFilterResolver backed by the shared
// access scope resolver, so account-level library restrictions (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.
// 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.
//
// PIN verification is skipped because compat login already verified the
// profile PIN (password#pin convention). Resolution failures fail closed:
+8 -55
View File
@@ -26,15 +26,12 @@ const (
type Authenticator struct {
sessions *SessionStore
authService *auth.Service
users auth.UserLoader
now func() time.Time
}
// 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}
// NewAuthenticator creates a new compat authenticator.
func NewAuthenticator(sessions *SessionStore, authService *auth.Service) *Authenticator {
return &Authenticator{sessions: sessions, authService: authService, now: time.Now}
}
// ExtractToken extracts a compat token from Jellyfin-style request auth.
@@ -97,19 +94,6 @@ 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.
@@ -164,46 +148,15 @@ 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 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) {
// matches neither. keyAuth may be nil (resolveSession handles a nil receiver).
func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *AdminAPIKeyAuthenticator, 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_") {
@@ -217,13 +170,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, users auth.UserLoader) func(next http.Handler) http.Handler {
func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator) 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, users, token); ok {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, token); ok {
serveWithSession(next, w, r, session)
return
}
@@ -240,7 +193,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, users, playSession.CompatToken); ok {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
serveWithSession(next, w, r, session)
return
}
+7 -9
View File
@@ -9,7 +9,6 @@ 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"
)
@@ -23,9 +22,13 @@ 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 auth.UserLoader
users apiKeyUserLoader
provider userstore.UserStoreProvider
now func() time.Time
@@ -50,12 +53,7 @@ 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.
//
// 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 {
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator {
if keys == nil || users == nil {
return nil
}
@@ -174,7 +172,7 @@ func (a *AdminAPIKeyAuthenticator) validate(ctx context.Context, token string) (
if err != nil || user == nil || !user.Enabled {
return nil, nil, unauthorized
}
if !user.IsAdmin {
if user.Role != "admin" {
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", IsAdmin: true, Enabled: true}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", Role: "admin", 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", Enabled: true}}
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Role: "user", 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, IsAdmin: true, Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", 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, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := PlaybackSessionAuth(sessions, playback, keyAuth)(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, nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
h := PlaybackSessionAuth(sessions, playback, keyAuth)(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,9 +315,8 @@ func TestValidatePseudoUser(t *testing.T) {
}
func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
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"}
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), Username: "admin"}
req := httptest.NewRequest(http.MethodGet, "/Users", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, session))
@@ -343,7 +342,7 @@ func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
}
func TestHandleUsers_RequiresSession(t *testing.T) {
h := NewAuthHandler(&config.Config{}, nil, nil, nil)
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
rec := httptest.NewRecorder()
h.HandleUsers(rec, httptest.NewRequest(http.MethodGet, "/Users", nil))
if rec.Code != http.StatusUnauthorized {
+9 -117
View File
@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
@@ -28,7 +27,7 @@ func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) {
StreamAppTokenExpiry: now.Add(3 * time.Minute),
})
authn := &Authenticator{sessions: store, authService: nil, users: enabledUserLoader(1)}
authn := &Authenticator{sessions: store, authService: nil}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "valid-tok")
rec := httptest.NewRecorder()
@@ -63,7 +62,7 @@ func TestRequireSession_PassesThroughNonExpiringToken(t *testing.T) {
StreamAppTokenExpiry: now.Add(30 * time.Minute),
})
authn := &Authenticator{sessions: store, users: enabledUserLoader(1)}
authn := &Authenticator{sessions: store}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "fresh-tok")
rec := httptest.NewRecorder()
@@ -98,7 +97,7 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
StreamAppTokenExpiry: now.Add(-1 * time.Hour), // already expired
})
authn := &Authenticator{sessions: store, authService: nil, users: enabledUserLoader(1)}
authn := &Authenticator{sessions: store, authService: nil}
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Emby-Token", "expired-tok")
rec := httptest.NewRecorder()
@@ -120,75 +119,6 @@ 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 }
@@ -197,7 +127,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, enabledUserLoader(1))
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
cases := []struct {
name string
@@ -243,48 +173,10 @@ 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, IsAdmin: true, Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -305,7 +197,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, Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}},
)
req := httptest.NewRequest("POST", "/Library/Media/Updated", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -323,7 +215,7 @@ func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{returnNilWithoutError: true},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -349,7 +241,7 @@ func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
return nil
},
},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
@@ -374,7 +266,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 auth.UserLoader) *AdminAPIKeyAuthenticator {
func newAdminAPIKeyAuthForTest(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator {
return NewAdminAPIKeyAuthenticator(keys, users, nil, nil)
}
+10 -53
View File
@@ -42,60 +42,8 @@ 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}},
}
rec := serveDownloadRequest(handler, codec, contentID)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != string(content) {
t.Errorf("expected file content %q; got %q", content, got)
}
if cd := rec.Header().Get("Content-Disposition"); cd == "" {
t.Error("expected Content-Disposition header on download response")
}
}
// 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()
@@ -106,7 +54,16 @@ func serveDownloadRequest(handler *PlaybackHandler, codec *ResourceIDCodec, cont
rec := httptest.NewRecorder()
handler.HandleDownload(rec, req)
return rec
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != string(content) {
t.Errorf("expected file content %q; got %q", content, got)
}
if cd := rec.Header().Get("Content-Disposition"); cd == "" {
t.Error("expected Content-Disposition header on download response")
}
}
// TestItemDetail_AdvertisesCanDownload guards against regressing the
+48 -110
View File
@@ -3,18 +3,13 @@ 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 {
@@ -120,20 +115,18 @@ type loginResolver interface {
// AuthHandler serves Jellyfin login/current-user routes.
type AuthHandler struct {
cfg *config.Config
cfg func() *config.Config
loginResolver loginResolver
authenticator *Authenticator
users auth.UserLoader
}
// 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 {
// 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 {
return &AuthHandler{
cfg: cfg,
loginResolver: loginResolver,
authenticator: authenticator,
users: users,
}
}
@@ -166,17 +159,10 @@ 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: dto,
ServerID: h.cfg().JellyfinCompat.ServerID,
User: h.userDTO(session),
SessionInfo: h.sessionInfo(session),
})
}
@@ -188,13 +174,7 @@ func (h *AuthHandler) HandleCurrentUser(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
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)
writeJSON(w, http.StatusOK, h.userDTO(session))
}
// HandleUsers serves GET /Users.
@@ -211,13 +191,7 @@ func (h *AuthHandler) HandleUsers(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
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})
writeJSON(w, http.StatusOK, []userDTOResponse{h.userDTO(session)})
}
// HandleUserByID serves GET /Users/{id}.
@@ -232,13 +206,7 @@ func (h *AuthHandler) HandleUserByID(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "NotFound", "User not found")
return
}
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)
writeJSON(w, http.StatusOK, h.userDTO(session))
}
// HandleLogout serves POST /Sessions/Logout.
@@ -252,33 +220,50 @@ func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// 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)
}
func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
name := session.Username
return userDTOResponse{
ID: session.PseudoUserID.String(),
Name: session.Username,
ServerID: h.cfg.JellyfinCompat.ServerID,
Name: name,
ServerID: h.cfg().JellyfinCompat.ServerID,
HasPassword: true,
HasConfiguredPassword: true,
HasConfiguredEasyPassword: false,
Policy: buildUserPolicy(user),
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",
},
Configuration: userConfigurationResponse{
PlayDefaultAudioTrack: true,
DisplayMissingEpisodes: false,
@@ -294,54 +279,7 @@ func (h *AuthHandler) userDTO(ctx context.Context, session *Session) (userDTORes
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 {
@@ -362,6 +300,6 @@ func (h *AuthHandler) sessionInfo(session *Session) *sessionInfoResponse {
SupportsRemoteControl: true,
HasCustomDeviceName: false,
SupportedCommands: []string{},
ServerID: h.cfg.JellyfinCompat.ServerID,
ServerID: h.cfg().JellyfinCompat.ServerID,
}
}
+3 -9
View File
@@ -21,7 +21,6 @@ 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"
@@ -33,10 +32,6 @@ 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"`
@@ -124,7 +119,6 @@ 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
@@ -670,7 +664,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
PixelFormat: track.PixelFormat,
AudioSpatialFormat: audioSpatialFormatNone,
AudioSpatialFormat: "None",
AverageFrameRate: parseCompatFrameRate(track.FrameRate),
RealFrameRate: parseCompatFrameRate(track.FrameRate),
ReferenceFrameRate: parseCompatFrameRate(track.FrameRate),
@@ -700,7 +694,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
IsHearingImpaired: false,
IsTextSubtitleStream: false,
SupportsExternalStream: false,
AudioSpatialFormat: audioSpatialFormatNone,
AudioSpatialFormat: "None",
Channels: track.Channels,
BitRate: track.Bitrate,
})
@@ -727,7 +721,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
IsHearingImpaired: track.HearingImpaired,
IsTextSubtitleStream: true,
SupportsExternalStream: track.External,
AudioSpatialFormat: audioSpatialFormatNone,
AudioSpatialFormat: "None",
DeliveryURL: subtitleDeliveryURL(routeItemID, mediaSourceID, streamIndex, format, compatToken, playSessionID),
DeliveryMethod: subtitleDeliveryMethod(track.External),
Path: subtitlePath(track, routeItemID, mediaSourceID, streamIndex, format),
+10 -8
View File
@@ -27,11 +27,13 @@ type endpointInfoResponse struct {
// SystemHandler serves Jellyfin setup/system endpoints.
type SystemHandler struct {
cfg *config.Config
cfg func() *config.Config
}
// NewSystemHandler creates a new system handler.
func NewSystemHandler(cfg *config.Config) *SystemHandler {
// 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 {
return &SystemHandler{cfg: cfg}
}
@@ -60,7 +62,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,
})
}
@@ -71,11 +73,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,
}
}
+5 -6
View File
@@ -61,12 +61,12 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(requestLoggerMiddleware)
r.Use(middleware.Recoverer)
systemHandler := NewSystemHandler(deps.Config)
systemHandler := NewSystemHandler(deps.CurrentConfig)
webFS, err := resolveCompatWebFS(deps)
if err != nil {
slog.Error("jellycompat web bundle unavailable", "error", err)
}
authHandler := NewAuthHandler(deps.Config, deps.LoginResolver, deps.Authenticator, deps.UserLoader)
authHandler := NewAuthHandler(deps.CurrentConfig, deps.LoginResolver, deps.Authenticator)
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.UserLoader, deps.UserStoreProvider, deps.Now)
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader, deps.UserStoreProvider, deps.Now)
autoscanVirtualFoldersRegistered := false
if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil {
r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)).
@@ -93,7 +93,6 @@ 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)
}
@@ -217,7 +216,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, deps.UserLoader))
r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore, adminAPIKeyAuth))
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))
@@ -335,7 +334,7 @@ func withDefaults(deps Dependencies) Dependencies {
}
if deps.Authenticator == nil && deps.SessionStore != nil {
deps.Authenticator = NewAuthenticator(deps.SessionStore, deps.AuthService, deps.UserLoader)
deps.Authenticator = NewAuthenticator(deps.SessionStore, deps.AuthService)
}
return deps
}
+18 -6
View File
@@ -23,7 +23,10 @@ import (
// Dependencies holds the pluggable pieces used by the compat server.
type Dependencies struct {
Config *config.Config
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
DB *pgxpool.Pool
SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set)
ClientIPResolver *clientip.Resolver
@@ -45,11 +48,9 @@ type Dependencies struct {
AuthService *auth.Service
// Autoscan / admin compatibility support.
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
APIKeyValidator apiKeyValidator
APIKeyUserLoader apiKeyUserLoader
ScanQueue scantrigger.Queuer
// Catalog repos (for ContentService construction)
BrowseRepo *catalog.BrowseRepository
@@ -86,6 +87,17 @@ type Dependencies struct {
S3Bucket string // optional
}
// CurrentConfig returns the live config when hot reload is wired, falling
// back to the startup snapshot otherwise.
func (d *Dependencies) CurrentConfig() *config.Config {
if d.LiveConfig != nil {
if cfg := d.LiveConfig(); cfg != nil {
return cfg
}
}
return d.Config
}
// Server wraps the compat HTTP handler.
type Server struct {
cfg *config.Config
-19
View File
@@ -122,10 +122,6 @@ 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 {
@@ -133,21 +129,6 @@ 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")
+26 -9
View File
@@ -4,23 +4,43 @@ 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 []string
prefixes *atomic.Pointer[[]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: ...".
// If quietCSV is empty, the inner handler is returned directly.
func New(inner slog.Handler, quietCSV string) slog.Handler {
// 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 {
trimmed := strings.TrimSpace(quietCSV)
if trimmed == "" {
return inner
return nil
}
parts := strings.Split(trimmed, ",")
prefixes := make([]string, 0, len(parts))
@@ -30,10 +50,7 @@ func New(inner slog.Handler, quietCSV string) slog.Handler {
prefixes = append(prefixes, p+":")
}
}
if len(prefixes) == 0 {
return inner
}
return &Handler{inner: inner, prefixes: prefixes}
return prefixes
}
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
@@ -42,7 +59,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 {
for _, prefix := range *h.prefixes.Load() {
if strings.HasPrefix(msg, prefix) {
return nil
}

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