From a24279e3e3031c7dfcca72627b99d24962cdd837 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:24:15 -0400 Subject: [PATCH 01/19] docs(notifications): import notification design docs, add web push + v1.5 specs Imports the notification system design folder (architecture overview, release-events/inbox foundation, APNs/FCM relay specs, outbound webhooks) and adds the Web Push spec (05, implemented in this branch), the shared outbound-email architecture note, and the v1.5 roadmap (06) covering the remaining work after APNs/FCM were deferred to v2. Co-Authored-By: Claude Fable 5 --- docs/architecture/email.md | 49 +++++ .../plans/notifications/05-web-push.md | 54 ++++++ .../plans/notifications/06-v1.5-roadmap.md | 169 ++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 docs/architecture/email.md create mode 100644 docs/superpowers/plans/notifications/05-web-push.md create mode 100644 docs/superpowers/plans/notifications/06-v1.5-roadmap.md diff --git a/docs/architecture/email.md b/docs/architecture/email.md new file mode 100644 index 00000000..baa1c8d3 --- /dev/null +++ b/docs/architecture/email.md @@ -0,0 +1,49 @@ +# Outbound Email (`internal/mail`) + +**Status:** Implemented 2026-06-11 + +Silo's shared outbound email facility. It is deliberately feature-agnostic: any +feature that sends mail (notification emails, account flows, invites) composes +a `mail.Message` and hands it to the shared `mail.Sender`, so SMTP +configuration, security policy, and diagnostics live in exactly one place. + +## Abstraction + +```go +type Sender interface { + Enabled(ctx context.Context) bool + Send(ctx context.Context, msg Message) error +} +``` + +`Message` carries recipients, subject, and text and/or HTML bodies (both set → +multipart/alternative). `Send` returns `mail.ErrNotConfigured` when email is +disabled or incomplete, so features treat email as an optional transport and +degrade gracefully. The SMTP implementation (`mail.NewSMTPSender`) is backed by +`github.com/wneessen/go-mail`. + +## Configuration + +Live server settings (no restart required; read on every send — volume is +low): + +| Key | Default | Notes | +|---|---|---| +| `email.enabled` | `false` | master switch | +| `email.smtp_host` | — | required | +| `email.smtp_port` | `587` | | +| `email.smtp_security` | `starttls` | `starttls` \| `tls` (implicit, port 465) \| `none` | +| `email.smtp_username` | — | empty = no auth | +| `email.smtp_password` | — | encrypted at rest (`SensitiveSettingKeys`) | +| `email.from_address` | — | required | +| `email.from_name` | `Silo` | | + +Admin UI: Admin Settings → Connections → Email, including a synchronous test +send (`POST /api/v1/admin/email/test`). + +## Adding a consumer + +Construct messages in the feature package and send through a `mail.Sender` +dependency. Do not read `email.*` settings from feature code, and check +`Enabled` (or branch on `ErrNotConfigured`) rather than treating a missing +SMTP configuration as an error. diff --git a/docs/superpowers/plans/notifications/05-web-push.md b/docs/superpowers/plans/notifications/05-web-push.md new file mode 100644 index 00000000..fb804c40 --- /dev/null +++ b/docs/superpowers/plans/notifications/05-web-push.md @@ -0,0 +1,54 @@ +# Web Push Spec + +**Date:** 2026-06-11 +**Status:** Implemented +**Scope:** Browser push notifications (Push API + VAPID) as a third push platform alongside the deferred APNs/FCM channels. +**Depends On:** +- [`00-architecture-overview.md`](./00-architecture-overview.md) +- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) + +## Why Web Push ships before APNs/FCM + +The architecture overview deferred mobile push because Apple and Google require pushes to official store builds to be signed by the publisher's credentials, forcing a Silo-operated relay. Web Push has neither problem: + +- **No accounts, no relay.** The server self-provisions a VAPID keypair on first use. Any standards-compliant browser push service (Chrome, Firefox, Edge, Safari 16+) accepts VAPID-signed requests from any origin. +- **Content-safe by protocol.** Payloads are encrypted end-to-end (RFC 8291, `aes128gcm`) to keys held only by the subscribed browser. The vendor push service relays ciphertext. Unlike the APNs/FCM design, payloads can therefore carry full display content (titles, episode numbers, poster URLs) without violating the self-hosted privacy model — there is no opaque-wake/fetch dance. + +The residual leak matches the relay threat model: the push service sees the user server's egress IP, delivery timing, and payload size. It never sees content or identity. + +## Data model + +- `web_push_subscriptions` — profile-scoped browser registrations: `endpoint` (unique; a resubscription from the same browser under a different profile reassigns the row), `p256dh`, `auth`, `device_name`, failure bookkeeping. No FK to profiles (per-user SQLite stores); profile deletion purges in code. +- `web_push_delivery_attempts` — the durable dispatch outbox, mirroring `webhook_delivery_attempts`: `pending` rows enqueued in the fanout transaction, claimed post-commit with a lease, swept by the retry loop after a crash. + +## VAPID identity + +Generated once and persisted in `server_settings`: + +- `notifications.web_push.vapid_public_key` — served to clients via the capability endpoint. +- `notifications.web_push.vapid_private_key` — encrypted at rest (`SensitiveSettingKeys`). + +The private key is persisted before the public key so a crash between writes regenerates the pair instead of stranding clients with an unusable public key. The pair must never be rotated casually: browsers bind subscriptions to it. + +## API surface (profile-scoped) + +- `GET /api/v1/notifications/capability` — `web_push: { available, public_key }`. +- `POST /api/v1/notifications/web-push/subscriptions` — body is `PushSubscription.toJSON()` plus `device_name`. +- `GET /api/v1/notifications/web-push/subscriptions` — for the settings UI device list. +- `DELETE /api/v1/notifications/web-push/subscriptions/{id}` +- `POST /api/v1/notifications/web-push/unsubscribe` — by endpoint (browsers don't know row IDs). + +Subscription endpoints are attacker-controllable URLs the server will POST to, so they pass the same HTTPS + private-destination guard as webhooks, both at registration and at connect time (guarded dialer). + +## Delivery semantics + +- Fanout enqueues one `pending` attempt per enabled subscription of each recipient profile, in the same transaction as the delivery rows. No per-reason filters: profile preferences already gate delivery creation. +- Retry schedule is short (30s/2m/10m/30m, 5 attempts): vendor push services queue messages for offline devices themselves (TTL 12h), so server-side retries only ride out transient push-service errors. +- `404`/`410` from the push service is the protocol's unsubscribe signal: the subscription row is deleted, not retried. +- `notifications.web_push_enabled` is the kill switch (default on). + +## Client + +- `web/public/sw.js` — displays notifications and routes clicks (episode deep link, or the inbox). +- `web/src/lib/webPush.ts` — permission + subscribe/unsubscribe flows. +- Settings → Notifications → "Browser Notifications" — this-browser toggle plus a revocable list of the profile's other subscribed devices. diff --git a/docs/superpowers/plans/notifications/06-v1.5-roadmap.md b/docs/superpowers/plans/notifications/06-v1.5-roadmap.md new file mode 100644 index 00000000..b423ca49 --- /dev/null +++ b/docs/superpowers/plans/notifications/06-v1.5-roadmap.md @@ -0,0 +1,169 @@ +# Notifications v1.5 Roadmap + +**Date:** 2026-06-11 +**Status:** Draft (work not started) +**Scope:** The remaining notification work between the shipped v1 and the deferred v2 push channels. Each item is independent and sized to land as its own PR. +**Depends On:** +- [`00-architecture-overview.md`](./00-architecture-overview.md) +- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — implemented +- [`04-outbound-webhooks.md`](./04-outbound-webhooks.md) — implemented +- [`05-web-push.md`](./05-web-push.md) — implemented + +## Where v1 landed (context for this doc) + +Implemented 2026-06-11: the full foundation (availability seeding, release events, +interest index, fanout worker with burst caps, websocket channel with ticket +handshake, inbox/sync/preferences/capability APIs, web inbox + badge + settings), +outbound webhooks (Discord + generic HMAC, SSRF guard, durable outbox, retry + +auto-disable), Web Push (VAPID self-provisioned, E2E-encrypted payloads, service +worker), and the shared SMTP core (`internal/mail`, see +`docs/architecture/email.md`) with an admin Email settings page — but no feature +consuming email yet. + +**Deferred to v2 by explicit decision:** APNs (`02`) and FCM (`03`) — they +require Silo-operated relay infrastructure and developer accounts. Also v2 per +the original plans: movie availability, aggregated notifications ("3 new +episodes"), quiet hours, cross-profile views. + +--- + +## 1. Admin settings UI for notification controls + +**Why:** every `notifications.*` setting works today but is reachable only +through the raw admin settings API. Admins should not need `curl` to find the +kill switches. + +**What:** an admin settings page ("Notifications", next to the Email page added +in v1) exposing: + +| Group | Keys | +|---|---| +| Kill switches | `notifications.release_events_enabled`, `notifications.fanout_enabled`, `notifications.ui_enabled`, `notifications.webhooks_enabled`, `notifications.web_push_enabled` | +| Fanout tuning | `notifications.fanout.settle_seconds` (default 30), `notifications.fanout.max_series_burst` (default 3) | +| Webhook guards | `notifications.webhooks.max_per_profile` (10), `notifications.webhooks.allow_private_destinations` (false; dev only — label it loudly), `notifications.webhooks.deliveries_per_minute_per_profile` (60) | +| Retention | `notifications.retention.read_days` (90), `notifications.retention.unread_days` (180), `notifications.retention.event_days` (30) | + +**Files:** add `web/src/pages/admin-settings/NotificationsAdminSettings.tsx` +(follow `EmailSettings.tsx` / `useSettingsForm`), register in +`web/src/pages/admin-settings/AdminSettingsLayout.tsx`. No backend work — all +keys are live-read. + +**Effort:** small (one page, no migrations, no Go changes). + +--- + +## 2. Request-fulfilled notifications (`request.fulfilled`) + +**Why:** `00-architecture-overview.md` calls this "the most obvious next +notification type." Users who request media currently learn it arrived by +checking manually; every delivery channel they configured should tell them. + +**Design:** the `notification_deliveries.type` registry is extensible by +construction — no schema change. + +- New type `request.fulfilled`. `reason_flags` carries the operational shape + (like `webhook.auto_disabled` does), e.g. + `{"request_id": "...", "tmdb_id": 123, "media_type": "movie"}` — never the + four reason booleans. +- **Hook point:** the request reconciliation service (`internal/mediarequests`) + is where a request transitions to available/fulfilled. On that transition, + insert a delivery via `DeliveryRepository.InsertOperational` (the path the + webhook auto-disable notice already uses) and publish through the system's + dispatchers so websocket, web push, and webhooks all fire. +- **Recipient:** the requesting profile (requests are profile-attributed). No + `profile_series_interest` involvement — this is a direct, not fanned-out, + notification. +- **Webhook enqueue:** operational inserts bypass the fanout outbox, so either + (a) extend `InsertOperational` to optionally enqueue per-target attempt rows, + or (b) add a small shared "dispatch one delivery durably" helper used by both + this and the auto-disable notice. Prefer (b); the auto-disable notice + deliberately skips webhooks (loop guard) but request notices should not. +- **Per-reason preferences:** add nothing in v1.5. The profile master toggle + (`notification_preferences.enabled`) gates it; a dedicated + `notify_requests` flag can come later if users ask. +- **Clients:** the web inbox/toast/web-push renderers fall back to a generic + card for unknown types; add a `request.fulfilled` case with the media title, + poster, and a deep link to the item (or the request page until matched). + +**Effort:** medium-small. The delivery/dispatch machinery all exists. + +--- + +## 3. Email digest channel + +**Why:** first real consumer of `internal/mail`; reaches users who don't keep a +browser open and have no webhook. + +**Open design decisions (resolve before building):** + +- **Account-level, not profile-level.** Email addresses live on `users`; + profiles have none. A digest therefore aggregates across the account's + profiles (group by profile inside the email body). +- **Digest, not per-episode.** Per-episode email is spam at hundreds-of-users + scale and duplicates the realtime channels. Recommend: opt-in daily digest of + unread deliveries, sent by a taskmanager task (reuse the checkpointed + iteration pattern from the interest backfill), with a per-user + enable + cadence setting. +- **Unsubscribe / preference surface:** account settings, not profile + notification preferences. + +**Files (sketch):** `internal/notifications/email_digest.go` (compose from +`DeliveryRepository`, send via `mail.Sender`, branch on +`mail.ErrNotConfigured`), a `taskmanager` task, a small user-settings surface. + +**Effort:** medium. Blocked on the design decisions above, not on plumbing. + +--- + +## 4. Native client adoption (no push required) + +**Why:** the Android and Apple apps gain a full notification experience today — +APNs/FCM only add closed-app wake-ups later. + +Server surfaces ready for clients (`silo-android`, `silo-apple`): + +- `GET /api/v1/notifications` + `unread-count` + read endpoints — inbox UI. +- `GET /api/v1/notifications/sync` — opaque forward cursor for + reconnect/foreground catch-up (this is also the wake-fetch endpoint the v2 + push specs assume, so client work done now is reused). +- `POST /api/v1/events/ws-ticket` + `ticket` query param on `/api/v1/events/ws`, + `notifications` channel — realtime while the app is open. Snapshot on + subscribe hydrates recent unread. +- `GET /api/v1/notifications/capability` — drive setup UI from this, never from + admin settings. +- `GET/PUT /api/v1/notifications/preferences` — per-profile reason toggles. + +**Effort:** client-repo work; the server side is done. Coordinate per the +multi-repo guidance in the repo root `CLAUDE.md`. + +--- + +## 5. Hardening backlog (defer freely) + +- **DB-backed integration tests** from the `01` verification plan: idempotent + availability/event inserts, cross-library delivery dedupe, per-series burst + cap, outbox recovery (pending rows with no dispatch → retry worker sends), + multi-node claim safety. The behaviors shipped and were exercised manually on + dev; they are not yet pinned by automated tests because the repo has no + Postgres test harness for this package. +- **Metrics:** `01` names Prometheus-style counters + (`release_events_suppressed_total`, etc.); v1 ships them as structured log + fields. Revisit when the repo grows a metrics registry — keep the names. +- **Discord embed images** via the `media.discord-cdn-proxy` service + (see `04`, "v1.5 payload"). Requires a new Silo-operated repo/service plus + `webhook_image_signer.go`; v1 deliberately ships text-only embeds so the + user's server origin never reaches Discord. +- **Webhook delivery history endpoint:** `webhook_delivery_attempts` already + has the listing index; a `GET /api/v1/notifications/webhooks/{id}/attempts` + endpoint + UI table would make failures self-diagnosable beyond the + last-failure summary. + +--- + +## Suggested order + +1. Admin settings UI (#1) — smallest, completes operability. +2. Request-fulfilled (#2) — highest product value per effort. +3. Webhook history endpoint (#5, last bullet) — pairs naturally with #1. +4. Email digest (#3) — after its design decisions are made. +5. Native clients (#4) — parallel track in the client repos. From b091f0c6c1117a7ef44a5fb4f38923b2ef6e9a53 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:24:52 -0400 Subject: [PATCH 02/19] feat(notifications): in-app inbox, realtime, webhooks, web push + shared SMTP core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the notification system foundation and all v1 delivery channels that need no external infrastructure (specs 00/01/04/05 in docs/superpowers/plans/notifications/): Foundation (spec 01): - episode_availability seeding + per-library seed markers: "newly available" means newly released to this server, so back-catalog imports and first scans never flood (verified on dev: 1.13M episodes seeded silently) - release_events -> profile_series_interest fanout worker with settling delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims, and a guarded last-notified cursor - interest index maintained via a userstore provider decorator so every favorites/watchlist/progress mutation path (REST, jellycompat, imports, playback) feeds it; progress writes only recompute on state transitions - durable per-profile inbox + read state, forward-sync cursor API, websocket channel with short-lived single-use handshake tickets - web UI: sidebar badge, inbox page, toasts, per-profile preferences - startup/daily tasks: availability seeding, interest rebuild, retention Outbound webhooks (spec 04): - Discord embeds (text-only per the v1 privacy contract) and generic JSON signed Stripe-style with per-webhook secrets - HTTPS-only + private-destination guard enforced at registration and at connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest - durable per-target outbox enqueued in the fanout transaction, lease-based claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an in-app notice (loop-guarded) Web push (spec 05): - VAPID keypair self-provisioned at startup (single atomic JSON setting, private half encrypted at rest) — no third-party accounts needed - payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe - service worker + subscribe flow in Settings -> Notifications Shared SMTP core (internal/mail): - feature-agnostic mail.Sender over live email.* settings, STARTTLS or implicit TLS, encrypted password, admin Email settings page with synchronous test send; no consumer yet by design (digest is v1.5) APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports them unavailable so clients render truthfully. Co-Authored-By: Claude Fable 5 --- cmd/silo/main.go | 49 ++ go.mod | 2 + go.sum | 66 ++ internal/api/handlers/email.go | 72 ++ internal/api/handlers/events_ws.go | 79 ++- internal/api/handlers/notifications.go | 353 ++++++++++ .../api/handlers/notifications_webhooks.go | 215 ++++++ .../api/handlers/notifications_webpush.go | 144 ++++ internal/api/router.go | 43 ++ internal/catalog/encrypted_settings_repo.go | 8 + internal/events/types.go | 5 + internal/libraryingest/executor.go | 19 + internal/mail/mail.go | 240 +++++++ internal/mail/mail_test.go | 121 ++++ .../notifications/availability_detector.go | 100 +++ internal/notifications/delivery_repo.go | 335 +++++++++ internal/notifications/dispatcher.go | 136 ++++ internal/notifications/episode_key.go | 31 + internal/notifications/fanout_logic.go | 77 ++ internal/notifications/fanout_logic_test.go | 213 ++++++ internal/notifications/fanout_worker.go | 452 ++++++++++++ internal/notifications/interest_hooks.go | 236 +++++++ internal/notifications/interest_repo.go | 164 +++++ internal/notifications/interest_updater.go | 407 +++++++++++ internal/notifications/preferences_repo.go | 98 +++ internal/notifications/release_repo.go | 282 ++++++++ internal/notifications/release_types.go | 139 ++++ internal/notifications/settings.go | 199 ++++++ internal/notifications/system.go | 643 +++++++++++++++++ internal/notifications/webhook_dispatcher.go | 140 ++++ internal/notifications/webhook_guard.go | 132 ++++ internal/notifications/webhook_http.go | 161 +++++ internal/notifications/webhook_logic_test.go | 318 +++++++++ .../notifications/webhook_payload_discord.go | 200 ++++++ .../notifications/webhook_payload_generic.go | 101 +++ internal/notifications/webhook_repo.go | 364 ++++++++++ internal/notifications/webhook_sender.go | 321 +++++++++ internal/notifications/webhook_service.go | 319 +++++++++ internal/notifications/webhook_types.go | 113 +++ internal/notifications/webpush_logic_test.go | 110 +++ internal/notifications/webpush_repo.go | 296 ++++++++ internal/notifications/webpush_sender.go | 341 +++++++++ internal/notifications/webpush_service.go | 171 +++++ internal/notifications/ws_ticket.go | 128 ++++ .../taskmanager/tasks/notifications_tasks.go | 142 ++++ ...11100000_profile_release_notifications.sql | 161 +++++ .../20260611120000_notification_webhooks.sql | 76 ++ .../20260611150000_web_push_subscriptions.sql | 65 ++ web/public/sw.js | 49 ++ web/src/App.tsx | 8 + web/src/api/types.ts | 117 +++- web/src/components/AppSidebar.test.tsx | 8 + web/src/components/AppSidebar.tsx | 48 ++ web/src/components/RealtimeEventsProvider.tsx | 126 +++- web/src/hooks/queries/keys.ts | 10 + web/src/hooks/queries/notificationWebhooks.ts | 121 ++++ web/src/hooks/queries/notifications.ts | 205 ++++++ web/src/lib/webPush.ts | 124 ++++ web/src/pages/Notifications.tsx | 362 ++++++++++ web/src/pages/SettingsLayout.tsx | 7 + .../admin-settings/AdminSettingsLayout.tsx | 3 + .../pages/admin-settings/EmailSettings.tsx | 182 +++++ .../pages/settings/NotificationsSettings.tsx | 656 ++++++++++++++++++ 63 files changed, 10600 insertions(+), 13 deletions(-) create mode 100644 internal/api/handlers/email.go create mode 100644 internal/api/handlers/notifications.go create mode 100644 internal/api/handlers/notifications_webhooks.go create mode 100644 internal/api/handlers/notifications_webpush.go create mode 100644 internal/mail/mail.go create mode 100644 internal/mail/mail_test.go create mode 100644 internal/notifications/availability_detector.go create mode 100644 internal/notifications/delivery_repo.go create mode 100644 internal/notifications/dispatcher.go create mode 100644 internal/notifications/episode_key.go create mode 100644 internal/notifications/fanout_logic.go create mode 100644 internal/notifications/fanout_logic_test.go create mode 100644 internal/notifications/fanout_worker.go create mode 100644 internal/notifications/interest_hooks.go create mode 100644 internal/notifications/interest_repo.go create mode 100644 internal/notifications/interest_updater.go create mode 100644 internal/notifications/preferences_repo.go create mode 100644 internal/notifications/release_repo.go create mode 100644 internal/notifications/release_types.go create mode 100644 internal/notifications/settings.go create mode 100644 internal/notifications/system.go create mode 100644 internal/notifications/webhook_dispatcher.go create mode 100644 internal/notifications/webhook_guard.go create mode 100644 internal/notifications/webhook_http.go create mode 100644 internal/notifications/webhook_logic_test.go create mode 100644 internal/notifications/webhook_payload_discord.go create mode 100644 internal/notifications/webhook_payload_generic.go create mode 100644 internal/notifications/webhook_repo.go create mode 100644 internal/notifications/webhook_sender.go create mode 100644 internal/notifications/webhook_service.go create mode 100644 internal/notifications/webhook_types.go create mode 100644 internal/notifications/webpush_logic_test.go create mode 100644 internal/notifications/webpush_repo.go create mode 100644 internal/notifications/webpush_sender.go create mode 100644 internal/notifications/webpush_service.go create mode 100644 internal/notifications/ws_ticket.go create mode 100644 internal/taskmanager/tasks/notifications_tasks.go create mode 100644 migrations/sql/20260611100000_profile_release_notifications.sql create mode 100644 migrations/sql/20260611120000_notification_webhooks.sql create mode 100644 migrations/sql/20260611150000_web_push_subscriptions.sql create mode 100644 web/public/sw.js create mode 100644 web/src/hooks/queries/notificationWebhooks.ts create mode 100644 web/src/hooks/queries/notifications.ts create mode 100644 web/src/lib/webPush.ts create mode 100644 web/src/pages/Notifications.tsx create mode 100644 web/src/pages/admin-settings/EmailSettings.tsx create mode 100644 web/src/pages/settings/NotificationsSettings.tsx diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 507e6cdb..d09db96e 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -595,6 +595,16 @@ func main() { bootstrapSensitiveValues["redis.url"] = bc.RedisURL } + // Shared Redis client for components needing raw Redis beyond the event + // bus (websocket handshake tickets, session listing). Nil on Redis-less + // deployments; consumers fall back to in-process implementations. + apiRedisClient, apiRedisErr := cache.NewRedisClient(cfg.Redis) + if apiRedisErr != nil { + slog.Warn("redis client init failed; multi-node websocket tickets disabled", "error", apiRedisErr) + } else if apiRedisClient != nil { + defer func() { _ = apiRedisClient.Close() }() + } + deps := api.Dependencies{ Config: cfg, LiveConfig: configWatcher.Config, @@ -605,6 +615,7 @@ func main() { DB: pool, SecretCipher: dataCipher, EventBus: eventBus, + RedisClient: apiRedisClient, LogStreamHub: logStreamHub, RealtimeHub: realtimeHub, EventsHub: eventsHub, @@ -1271,6 +1282,39 @@ func main() { } defer userStoreProvider.Close() } + + // User-facing release notifications. The system reads user state through + // the raw store provider; the provider handed to everything downstream is + // wrapped so every favorites/watchlist/progress mutation (REST handlers, + // jellycompat, imports, playback) feeds the interest index. + var notificationSystem *notifications.System + if deps.DB != nil && userStoreProvider != nil { + notificationScopes := access.NewResolver( + auth.NewUserRepository(deps.DB), + userStoreProvider, + access.NewProfileTokenService(cfg.Auth.JWTSecret, 0), + ) + notificationSystem = notifications.NewSystem( + deps.DB, + settingsRepo, + userStoreProvider, + notificationScopes, + auth.NewUserRepository(deps.DB), + deps.EventsHub, + deps.RedisClient, + deps.SecretCipher, + ) + userStoreProvider = notifications.WrapUserStoreProvider(userStoreProvider, notificationSystem) + deps.Notifications = notificationSystem + if libraryIngestExecutor != nil { + libraryIngestExecutor.SetAvailabilityDetector(notificationSystem.Detector) + } + if needsWorkers { + notificationSystem.Start(appCtx) + defer notificationSystem.Wait() + } + } + if userStoreProvider != nil && pluginService != nil { deps.PluginUserConfig = plugins.NewUserConfigStore(userStoreProvider, pluginService) } @@ -1575,6 +1619,11 @@ func main() { } taskMgr.Register(tasks.NewActivityLogCleanupTask(deps.DB, settingsRepo, activityPM)) taskMgr.Register(tasks.NewOperationalLogCleanupTask(deps.DB, settingsRepo, opsPM)) + if notificationSystem != nil { + taskMgr.Register(tasks.NewSeedEpisodeAvailabilityTask(notificationSystem)) + taskMgr.Register(tasks.NewRebuildReleaseInterestTask(notificationSystem)) + taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationSystem)) + } if matchWorker != nil { taskMgr.Register(tasks.NewMatchMediaTask(matchWorker)) } diff --git a/go.mod b/go.mod index bb9ec600..8f652015 100644 --- a/go.mod +++ b/go.mod @@ -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,6 +31,7 @@ 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/wneessen/go-mail v0.7.3 github.com/zishang520/socket.io/v2 v2.5.0 go.n16f.net/thumbhash v1.1.0 golang.org/x/image v0.41.0 diff --git a/go.sum b/go.sum index 58843ee5..23fc5643 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ 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= @@ -69,10 +71,12 @@ 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/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= @@ -209,12 +213,15 @@ github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vb github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0= +github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= 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= @@ -249,31 +256,90 @@ 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-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-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.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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.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-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/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= diff --git a/internal/api/handlers/email.go b/internal/api/handlers/email.go new file mode 100644 index 00000000..751c3821 --- /dev/null +++ b/internal/api/handlers/email.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "net/mail" + "time" + + silomail "github.com/Silo-Server/silo-server/internal/mail" +) + +// EmailHandler exposes admin operations for the shared outbound email +// facility (internal/mail). Feature-specific email content lives with the +// features; this handler only owns configuration verification. +type EmailHandler struct { + sender silomail.Sender +} + +// NewEmailHandler creates an EmailHandler. +func NewEmailHandler(sender silomail.Sender) *EmailHandler { + return &EmailHandler{sender: sender} +} + +type emailTestRequest struct { + To string `json:"to"` +} + +type emailTestResponse struct { + OK bool `json:"ok"` + DurationMS int64 `json:"duration_ms"` + Message string `json:"message,omitempty"` +} + +// HandleTest handles POST /admin/email/test: synchronously sends a test +// message so admins can verify SMTP settings before any feature depends on +// them. +func (h *EmailHandler) HandleTest(w http.ResponseWriter, r *http.Request) { + if h == nil || h.sender == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Email is not available") + return + } + var req emailTestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if _, err := mail.ParseAddress(req.To); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "A valid recipient address is required") + return + } + + started := time.Now() + err := h.sender.Send(r.Context(), silomail.Message{ + To: []string{req.To}, + Subject: "Silo test email", + TextBody: "This is a test email from your Silo server.\n\n" + + "If you received it, outbound email is configured correctly.", + }) + response := emailTestResponse{ + OK: err == nil, + DurationMS: time.Since(started).Milliseconds(), + } + switch { + case err == nil: + case errors.Is(err, silomail.ErrNotConfigured): + response.Message = "Email is not configured. Set the SMTP host, from address, and enable email first." + default: + response.Message = err.Error() + } + writeJSON(w, http.StatusOK, response) +} diff --git a/internal/api/handlers/events_ws.go b/internal/api/handlers/events_ws.go index a6b668ff..7151e138 100644 --- a/internal/api/handlers/events_ws.go +++ b/internal/api/handlers/events_ws.go @@ -12,6 +12,7 @@ import ( "github.com/Silo-Server/silo-server/internal/auth" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/historyimport" + "github.com/Silo-Server/silo-server/internal/notifications" "github.com/Silo-Server/silo-server/internal/scanqueue" "github.com/Silo-Server/silo-server/internal/taskmanager" "github.com/gorilla/websocket" @@ -39,6 +40,15 @@ type EventsHandler struct { scans *evt.ScanRegistry persistedScans activeScanLister historyImports historyImportActiveLister + notifications *notifications.System +} + +// SetNotificationsSystem wires the user-notification system: websocket +// handshake tickets and the notifications channel snapshot. +func (h *EventsHandler) SetNotificationsSystem(system *notifications.System) { + if h != nil { + h.notifications = system + } } func NewEventsHandler( @@ -73,6 +83,27 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) return } + // Browsers cannot set custom headers on websocket handshakes, so profile + // identity arrives as a short-lived single-use ticket minted via + // POST /events/ws-ticket. A connection without a ticket stays unbound and + // simply cannot subscribe to the profile-scoped notifications channel. + boundProfileID := "" + if ticket := r.URL.Query().Get("ticket"); ticket != "" && h.notifications != nil { + ticketUserID, ticketProfileID, ok := h.notifications.Tickets.Consume(r.Context(), ticket) + if ok && ticketUserID == claims.UserID { + boundProfileID = ticketProfileID + } else { + // Expired, reused, consumed on a different node, or minted for + // another user: degrade to an unbound connection instead of + // failing the handshake. The binding grants nothing on its own — + // the client retries it when its notifications subscription is + // rejected — whereas a hard 403 would take down every realtime + // channel over a notifications-only concern. + slog.Warn("events: websocket ticket rejected; connection unbound", + "user_id", claims.UserID) + } + } + conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { return @@ -142,7 +173,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) ) return case data := <-readMessages: - nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels) + nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, boundProfileID, data, allowedChannels) if !ok { return } @@ -164,10 +195,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) if _, subscribed := subscriptions[env.Channel]; !subscribed { continue } - if !allowsEventForClaims(claims, env) { + if !allowsEventForClaims(claims, boundProfileID, env) { continue } - if err := h.writeEventFrame(conn, r, claims, env); err != nil { + if err := h.writeEventFrame(conn, r, claims, boundProfileID, env); err != nil { return } } @@ -178,6 +209,7 @@ func (h *EventsHandler) handleEventsClientMessage( conn *websocket.Conn, r *http.Request, claims *auth.Claims, + boundProfileID string, data []byte, allowed []evt.EventChannel, ) (map[evt.EventChannel]struct{}, bool, bool) { @@ -245,6 +277,16 @@ func (h *EventsHandler) handleEventsClientMessage( }) continue } + // The notifications channel is profile-scoped: it requires a + // connection bound to a profile via a websocket ticket. + if channel == evt.ChannelNotifications && boundProfileID == "" { + rejected = append(rejected, evt.EventsRejectedChannel{ + Channel: channel, + Code: "profile_required", + Message: "A profile-bound websocket ticket is required", + }) + continue + } if _, seen := nextSubs[channel]; seen { continue } @@ -262,7 +304,7 @@ func (h *EventsHandler) handleEventsClientMessage( } for _, channel := range accepted { - if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil { + if err := h.writeSnapshotFrame(conn, r, claims, boundProfileID, channel); err != nil { return nil, false, false } } @@ -275,6 +317,7 @@ func allowedChannelsForRole(role string) []evt.EventChannel { evt.ChannelCatalog, evt.ChannelHistoryImport, evt.ChannelUserState, + evt.ChannelNotifications, } if role == "admin" { channels = append(channels, @@ -287,13 +330,20 @@ func allowedChannelsForRole(role string) []evt.EventChannel { return channels } -func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool { +func allowsEventForClaims(claims *auth.Claims, boundProfileID string, env evt.Envelope) bool { if claims == nil { return false } if env.AdminOnly && claims.Role != "admin" { return false } + if env.Channel == evt.ChannelNotifications { + // Notifications are personal: even admins only receive their own + // profile's deliveries, and only on a profile-bound connection. + return boundProfileID != "" && + env.UserID == claims.UserID && + env.ProfileID == boundProfileID + } if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID { return false } @@ -314,11 +364,24 @@ func marshalJSON(value any) json.RawMessage { func (h *EventsHandler) snapshotForChannel( r *http.Request, claims *auth.Claims, + boundProfileID string, channel evt.EventChannel, ) (json.RawMessage, error) { switch channel { case evt.ChannelCatalog, evt.ChannelUserState: return json.RawMessage("null"), nil + case evt.ChannelNotifications: + // Recent unread deliveries for the bound profile so reconnecting + // clients hydrate without a separate REST call. Same row shape as the + // inbox list API. + if h == nil || h.notifications == nil || boundProfileID == "" { + return json.RawMessage("[]"), nil + } + rows, err := h.notifications.Deliveries.RecentUnread(r.Context(), boundProfileID, 25) + if err != nil { + return nil, err + } + return marshalJSON(h.notifications.PayloadsForRows(r.Context(), rows)), nil case evt.ChannelJobs: if h == nil || h.jobs == nil || h.jobs.repo == nil { return json.RawMessage("[]"), nil @@ -387,9 +450,10 @@ func (h *EventsHandler) writeSnapshotFrame( conn *websocket.Conn, r *http.Request, claims *auth.Claims, + boundProfileID string, channel evt.EventChannel, ) error { - data, err := h.snapshotForChannel(r, claims, channel) + data, err := h.snapshotForChannel(r, claims, boundProfileID, channel) if err != nil { slog.Error( "events: failed to build initial snapshot", @@ -415,11 +479,12 @@ func (h *EventsHandler) writeEventFrame( conn *websocket.Conn, r *http.Request, claims *auth.Claims, + boundProfileID string, env evt.Envelope, ) error { data := env.Data if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") { - snapshot, err := h.snapshotForChannel(r, claims, env.Channel) + snapshot, err := h.snapshotForChannel(r, claims, boundProfileID, env.Channel) if err != nil { slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err) return err diff --git a/internal/api/handlers/notifications.go b/internal/api/handlers/notifications.go new file mode 100644 index 00000000..545ba7c9 --- /dev/null +++ b/internal/api/handlers/notifications.go @@ -0,0 +1,353 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/go-chi/chi/v5" +) + +const ( + notificationsDefaultLimit = 25 + notificationsMaxLimit = 100 + notificationsSyncLimit = 50 +) + +// NotificationsHandler serves the profile-scoped notification inbox, +// preferences, capability, and websocket-ticket endpoints. All routes are +// mounted behind RequireProfile. +type NotificationsHandler struct { + system *notifications.System + hub *evt.Hub +} + +// NewNotificationsHandler creates a NotificationsHandler. +func NewNotificationsHandler(system *notifications.System, hub *evt.Hub) *NotificationsHandler { + return &NotificationsHandler{system: system, hub: hub} +} + +type notificationListResponse struct { + Notifications []notifications.DeliveryRowPayload `json:"notifications"` + // NextCursor pages further into the past via the `before` query param. + // Empty when this page may be the last. + NextCursor string `json:"next_cursor,omitempty"` +} + +type notificationSyncResponse struct { + Notifications []notifications.DeliveryRowPayload `json:"notifications"` + NextCursor string `json:"next_cursor,omitempty"` + UnreadCount int `json:"unread_count"` +} + +type unreadCountResponse struct { + Count int `json:"count"` +} + +type wsTicketResponse struct { + Ticket string `json:"ticket"` + ExpiresIn int `json:"expires_in"` +} + +func parseNotificationsLimit(r *http.Request, fallback int) int { + raw := r.URL.Query().Get("limit") + if raw == "" { + return fallback + } + limit, err := strconv.Atoi(raw) + if err != nil || limit <= 0 { + return fallback + } + return min(limit, notificationsMaxLimit) +} + +// HandleList handles GET /notifications (newest-first inbox page). +func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + unreadOnly := r.URL.Query().Get("status") == "unread" + limit := parseNotificationsLimit(r, notificationsDefaultLimit) + + var before *notifications.Cursor + if raw := r.URL.Query().Get("before"); raw != "" { + cursor, err := notifications.DecodeCursor(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid before cursor") + return + } + before = &cursor + } + + rows, err := h.system.Deliveries.ListInbox(r.Context(), profileID, unreadOnly, limit, before) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications") + return + } + response := notificationListResponse{Notifications: h.system.PayloadsForRows(r.Context(), rows)} + if len(rows) == limit { + last := rows[len(rows)-1] + response.NextCursor = notifications.Cursor{CreatedAt: last.CreatedAt, ID: last.ID}.Encode() + } + writeJSON(w, http.StatusOK, response) +} + +// HandleSync handles GET /notifications/sync — the forward (ascending) cursor +// sync used by clients waking from a push or reconnecting after a gap. +func (h *NotificationsHandler) HandleSync(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + limit := parseNotificationsLimit(r, notificationsSyncLimit) + + var since *notifications.Cursor + if raw := r.URL.Query().Get("since"); raw != "" { + cursor, err := notifications.DecodeCursor(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid since cursor") + return + } + since = &cursor + } + + rows, err := h.system.Deliveries.ListSync(r.Context(), profileID, since, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync notifications") + return + } + unread, err := h.system.Deliveries.UnreadCount(r.Context(), profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unread notifications") + return + } + + response := notificationSyncResponse{ + Notifications: h.system.PayloadsForRows(r.Context(), rows), + UnreadCount: unread, + } + if len(rows) > 0 { + last := rows[len(rows)-1] + response.NextCursor = notifications.Cursor{CreatedAt: last.CreatedAt, ID: last.ID}.Encode() + } else if since != nil { + response.NextCursor = since.Encode() + } + writeJSON(w, http.StatusOK, response) +} + +// HandleGet handles GET /notifications/{id}; 404 for other profiles' rows. +func (h *NotificationsHandler) HandleGet(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + id := chi.URLParam(r, "id") + + row, err := h.system.Deliveries.GetByID(r.Context(), profileID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification") + return + } + if row == nil { + writeError(w, http.StatusNotFound, "not_found", "Notification not found") + return + } + writeJSON(w, http.StatusOK, h.system.PayloadForRow(r.Context(), *row)) +} + +// HandleUnreadCount handles GET /notifications/unread-count. +func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + count, err := h.system.Deliveries.UnreadCount(r.Context(), profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unread notifications") + return + } + writeJSON(w, http.StatusOK, unreadCountResponse{Count: count}) +} + +// HandleMarkRead handles POST /notifications/{id}/read. Idempotent. +func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + id := chi.URLParam(r, "id") + + transitioned, err := h.system.Deliveries.MarkRead(r.Context(), profileID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notification read") + return + } + if !transitioned { + // Already read is fine (idempotent); unknown IDs are a 404. + exists, err := h.system.Deliveries.Exists(r.Context(), profileID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notification read") + return + } + if !exists { + writeError(w, http.StatusNotFound, "not_found", "Notification not found") + return + } + } + if transitioned { + h.publishReadEvent(r, userID, profileID, id) + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleReadAll handles POST /notifications/read-all. +func (h *NotificationsHandler) HandleReadAll(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + if _, err := h.system.Deliveries.MarkAllRead(r.Context(), profileID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read") + return + } + h.publishReadEvent(r, userID, profileID, "") + w.WriteHeader(http.StatusNoContent) +} + +// publishReadEvent lets other connected tabs of the same profile reconcile +// read state. An empty id means "all read". +func (h *NotificationsHandler) publishReadEvent(r *http.Request, userID int, profileID, id string) { + if h.hub == nil { + return + } + payload := map[string]any{"profile_id": profileID} + if id != "" { + payload["id"] = id + } else { + payload["all"] = true + } + _ = h.hub.PublishJSON(r.Context(), evt.ChannelNotifications, notifications.EventNotificationRead, + payload, evt.PublishOptions{UserID: userID, ProfileID: profileID}) +} + +// HandleGetPreferences handles GET /notifications/preferences. +func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + prefs, err := h.system.Preferences.Get(r.Context(), profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification preferences") + return + } + writeJSON(w, http.StatusOK, prefs) +} + +type updatePreferencesRequest struct { + Enabled *bool `json:"enabled"` + NotifyFavorites *bool `json:"notify_favorites"` + NotifyWatchlist *bool `json:"notify_watchlist"` + NotifyContinueWatching *bool `json:"notify_continue_watching"` + NotifyNextUp *bool `json:"notify_next_up"` +} + +// HandleUpdatePreferences handles PUT /notifications/preferences. Fields are +// optional; omitted fields keep their current value. Idempotent. +func (h *NotificationsHandler) HandleUpdatePreferences(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + + var req updatePreferencesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + prefs, err := h.system.Preferences.Get(r.Context(), profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification preferences") + return + } + if req.Enabled != nil { + prefs.Enabled = *req.Enabled + } + if req.NotifyFavorites != nil { + prefs.NotifyFavorites = *req.NotifyFavorites + } + if req.NotifyWatchlist != nil { + prefs.NotifyWatchlist = *req.NotifyWatchlist + } + if req.NotifyContinueWatching != nil { + prefs.NotifyContinueWatching = *req.NotifyContinueWatching + } + if req.NotifyNextUp != nil { + prefs.NotifyNextUp = *req.NotifyNextUp + } + if err := h.system.Preferences.Upsert(r.Context(), prefs); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save notification preferences") + return + } + writeJSON(w, http.StatusOK, prefs) +} + +type capabilityResponse struct { + InApp capabilityInApp `json:"in_app"` + ApplePush capabilityPush `json:"apple_push"` + AndroidPush capabilityPush `json:"android_push"` + WebPush capabilityWebPush `json:"web_push"` + Webhooks capabilityWebhooks `json:"webhooks"` +} + +type capabilityWebPush struct { + Available bool `json:"available"` + PublicKey string `json:"public_key,omitempty"` +} + +type capabilityInApp struct { + Enabled bool `json:"enabled"` +} + +type capabilityPush struct { + Available bool `json:"available"` + Provider string `json:"provider"` + SupportedModes []string `json:"supported_modes"` +} + +type capabilityWebhooks struct { + Available bool `json:"available"` + MaxPerProfile int `json:"max_per_profile"` + SupportedTypes []string `json:"supported_types"` +} + +// HandleCapability handles GET /notifications/capability. Clients render +// setup UI from this response instead of introspecting admin settings. Push +// channels report unavailable until they ship +// (docs/superpowers/plans/notifications/02-03). +func (h *NotificationsHandler) HandleCapability(w http.ResponseWriter, r *http.Request) { + webhooks := capabilityWebhooks{Available: false, MaxPerProfile: 0, SupportedTypes: []string{}} + if h.system.Webhooks != nil && h.system.Settings.WebhooksEnabled(r.Context()) { + webhooks = capabilityWebhooks{ + Available: true, + MaxPerProfile: h.system.Settings.WebhooksMaxPerProfile(r.Context()), + SupportedTypes: []string{"discord", "generic"}, + } + } + webPush := capabilityWebPush{} + if h.system.WebPush != nil && h.system.Settings.WebPushEnabled(r.Context()) { + if publicKey, err := h.system.WebPush.PublicKey(r.Context()); err == nil && publicKey != "" { + webPush = capabilityWebPush{Available: true, PublicKey: publicKey} + } + } + writeJSON(w, http.StatusOK, capabilityResponse{ + InApp: capabilityInApp{Enabled: h.system.Settings.UIEnabled(r.Context())}, + ApplePush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}, + AndroidPush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}, + WebPush: webPush, + Webhooks: webhooks, + }) +} + +// HandleMintWSTicket handles POST /events/ws-ticket: mints a short-lived +// single-use websocket handshake ticket bound to (user, profile). Long-lived +// tokens must never ride the websocket query string — reverse-proxy access +// logs capture it. +func (h *NotificationsHandler) HandleMintWSTicket(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + ticket, ttl, err := h.system.Tickets.Mint(r.Context(), userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mint websocket ticket") + return + } + writeJSON(w, http.StatusOK, wsTicketResponse{ + Ticket: ticket, + ExpiresIn: int(ttl.Seconds()), + }) +} diff --git a/internal/api/handlers/notifications_webhooks.go b/internal/api/handlers/notifications_webhooks.go new file mode 100644 index 00000000..132049eb --- /dev/null +++ b/internal/api/handlers/notifications_webhooks.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/go-chi/chi/v5" +) + +// webhookResponse is the API view of a webhook. It never includes the +// destination URL (Discord webhook tokens are bearer credentials in the URL +// path) or the signing secret — only url_host for identification. +type webhookResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URLHost string `json:"url_host"` + Enabled bool `json:"enabled"` + NotifyFavorites bool `json:"notify_favorites"` + NotifyWatchlist bool `json:"notify_watchlist"` + NotifyContinueWatching bool `json:"notify_continue_watching"` + NotifyNextUp bool `json:"notify_next_up"` + ConsecutiveFailures int `json:"consecutive_failures"` + DisabledReason *string `json:"disabled_reason"` + LastSuccessAt *time.Time `json:"last_success_at"` + LastFailureAt *time.Time `json:"last_failure_at"` + LastFailureStatus *int `json:"last_failure_status"` + LastFailureMessage *string `json:"last_failure_message"` + // SigningSecret is present only in create / rotate-secret responses. + SigningSecret string `json:"signing_secret,omitempty"` +} + +func webhookToResponse(hook notifications.Webhook) webhookResponse { + return webhookResponse{ + ID: hook.ID, + Name: hook.Name, + Type: hook.Type, + URLHost: hook.URLHost, + Enabled: hook.Enabled, + NotifyFavorites: hook.NotifyFavorites, + NotifyWatchlist: hook.NotifyWatchlist, + NotifyContinueWatching: hook.NotifyContinueWatching, + NotifyNextUp: hook.NotifyNextUp, + ConsecutiveFailures: hook.ConsecutiveFailures, + DisabledReason: hook.DisabledReason, + LastSuccessAt: hook.LastSuccessAt, + LastFailureAt: hook.LastFailureAt, + LastFailureStatus: hook.LastFailureStatus, + LastFailureMessage: hook.LastFailureMessage, + } +} + +type webhookRequest struct { + Name *string `json:"name"` + URL *string `json:"url"` + Type *string `json:"type"` + Enabled *bool `json:"enabled"` + NotifyFavorites *bool `json:"notify_favorites"` + NotifyWatchlist *bool `json:"notify_watchlist"` + NotifyContinueWatching *bool `json:"notify_continue_watching"` + NotifyNextUp *bool `json:"notify_next_up"` +} + +func (r webhookRequest) toInput() notifications.WebhookInput { + return notifications.WebhookInput{ + Name: r.Name, + URL: r.URL, + Type: r.Type, + Enabled: r.Enabled, + NotifyFavorites: r.NotifyFavorites, + NotifyWatchlist: r.NotifyWatchlist, + NotifyContinueWatching: r.NotifyContinueWatching, + NotifyNextUp: r.NotifyNextUp, + } +} + +func (h *NotificationsHandler) webhooks() *notifications.WebhookService { + if h == nil || h.system == nil { + return nil + } + return h.system.Webhooks +} + +func writeWebhookError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, notifications.ErrWebhookNotFound): + writeError(w, http.StatusNotFound, "not_found", "Webhook not found") + case errors.Is(err, notifications.ErrWebhookLimit): + writeError(w, http.StatusUnprocessableEntity, "limit_reached", "Webhook limit reached for this profile") + case errors.Is(err, notifications.ErrWebhookInvalid): + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Webhook operation failed") + } +} + +// HandleListWebhooks handles GET /notifications/webhooks. +func (h *NotificationsHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request) { + service := h.webhooks() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available") + return + } + profileID := apimw.GetProfileID(r.Context()) + hooks, err := service.List(r.Context(), profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list webhooks") + return + } + responses := make([]webhookResponse, 0, len(hooks)) + for _, hook := range hooks { + responses = append(responses, webhookToResponse(hook)) + } + writeJSON(w, http.StatusOK, map[string]any{"webhooks": responses}) +} + +// HandleCreateWebhook handles POST /notifications/webhooks. For generic +// webhooks the response carries the signing secret exactly once. +func (h *NotificationsHandler) HandleCreateWebhook(w http.ResponseWriter, r *http.Request) { + service := h.webhooks() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available") + return + } + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + var req webhookRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + hook, signingSecret, err := service.Create(r.Context(), userID, profileID, req.toInput()) + if err != nil { + writeWebhookError(w, err) + return + } + response := webhookToResponse(*hook) + response.SigningSecret = signingSecret + writeJSON(w, http.StatusCreated, response) +} + +// HandleUpdateWebhook handles PUT /notifications/webhooks/{id}. +func (h *NotificationsHandler) HandleUpdateWebhook(w http.ResponseWriter, r *http.Request) { + service := h.webhooks() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available") + return + } + profileID := apimw.GetProfileID(r.Context()) + + var req webhookRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + hook, err := service.Update(r.Context(), profileID, chi.URLParam(r, "id"), req.toInput()) + if err != nil { + writeWebhookError(w, err) + return + } + writeJSON(w, http.StatusOK, webhookToResponse(*hook)) +} + +// HandleDeleteWebhook handles DELETE /notifications/webhooks/{id}. Idempotent. +func (h *NotificationsHandler) HandleDeleteWebhook(w http.ResponseWriter, r *http.Request) { + service := h.webhooks() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available") + return + } + profileID := apimw.GetProfileID(r.Context()) + if err := service.Delete(r.Context(), profileID, chi.URLParam(r, "id")); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete webhook") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleRotateWebhookSecret handles POST /notifications/webhooks/{id}/rotate-secret. +func (h *NotificationsHandler) HandleRotateWebhookSecret(w http.ResponseWriter, r *http.Request) { + service := h.webhooks() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available") + return + } + profileID := apimw.GetProfileID(r.Context()) + signingSecret, err := service.RotateSecret(r.Context(), profileID, chi.URLParam(r, "id")) + if err != nil { + writeWebhookError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"signing_secret": signingSecret}) +} + +// HandleTestWebhook handles POST /notifications/webhooks/{id}/test. The test +// send is synchronous and never touches the retry/auto-disable counters. +func (h *NotificationsHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request) { + service := h.webhooks() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available") + return + } + profileID := apimw.GetProfileID(r.Context()) + result, err := service.Test(r.Context(), profileID, chi.URLParam(r, "id")) + if err != nil { + writeWebhookError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} diff --git a/internal/api/handlers/notifications_webpush.go b/internal/api/handlers/notifications_webpush.go new file mode 100644 index 00000000..c9de9963 --- /dev/null +++ b/internal/api/handlers/notifications_webpush.go @@ -0,0 +1,144 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/go-chi/chi/v5" +) + +// webPushSubscriptionResponse is the API view of a browser push +// registration. The keys are write-only: clients re-subscribe rather than +// read them back. +type webPushSubscriptionResponse struct { + ID string `json:"id"` + Endpoint string `json:"endpoint"` + DeviceName string `json:"device_name,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + LastSuccessAt *time.Time `json:"last_success_at"` + LastFailureAt *time.Time `json:"last_failure_at"` +} + +func webPushToResponse(sub notifications.WebPushSubscription) webPushSubscriptionResponse { + return webPushSubscriptionResponse{ + ID: sub.ID, + Endpoint: sub.Endpoint, + DeviceName: sub.DeviceName, + Enabled: sub.Enabled, + CreatedAt: sub.CreatedAt, + LastSuccessAt: sub.LastSuccessAt, + LastFailureAt: sub.LastFailureAt, + } +} + +func (h *NotificationsHandler) webPush() *notifications.WebPushService { + if h == nil || h.system == nil { + return nil + } + return h.system.WebPush +} + +type webPushSubscribeRequest struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` + DeviceName string `json:"device_name"` +} + +// HandleWebPushSubscribe handles POST /notifications/web-push/subscriptions. +// The body matches PushSubscription.toJSON() plus an optional device name. +func (h *NotificationsHandler) HandleWebPushSubscribe(w http.ResponseWriter, r *http.Request) { + service := h.webPush() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Web push is not available") + return + } + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + var req webPushSubscribeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + sub, err := service.Subscribe(r.Context(), userID, profileID, + req.Endpoint, req.Keys.P256dh, req.Keys.Auth, req.DeviceName) + if err != nil { + if errors.Is(err, notifications.ErrWebPushInvalid) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push subscription") + return + } + writeJSON(w, http.StatusCreated, webPushToResponse(*sub)) +} + +// HandleWebPushList handles GET /notifications/web-push/subscriptions. +func (h *NotificationsHandler) HandleWebPushList(w http.ResponseWriter, r *http.Request) { + service := h.webPush() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Web push is not available") + return + } + profileID := apimw.GetProfileID(r.Context()) + subs, err := service.List(r.Context(), profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list push subscriptions") + return + } + responses := make([]webPushSubscriptionResponse, 0, len(subs)) + for _, sub := range subs { + responses = append(responses, webPushToResponse(sub)) + } + writeJSON(w, http.StatusOK, map[string]any{"subscriptions": responses}) +} + +// HandleWebPushDelete handles DELETE /notifications/web-push/subscriptions/{id}. +func (h *NotificationsHandler) HandleWebPushDelete(w http.ResponseWriter, r *http.Request) { + service := h.webPush() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Web push is not available") + return + } + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + if err := service.Unsubscribe(r.Context(), userID, profileID, chi.URLParam(r, "id"), ""); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove push subscription") + return + } + w.WriteHeader(http.StatusNoContent) +} + +type webPushUnsubscribeRequest struct { + Endpoint string `json:"endpoint"` +} + +// HandleWebPushUnsubscribe handles POST /notifications/web-push/unsubscribe. +// Browsers only know their endpoint, not the server-side row ID. Idempotent. +func (h *NotificationsHandler) HandleWebPushUnsubscribe(w http.ResponseWriter, r *http.Request) { + service := h.webPush() + if service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Web push is not available") + return + } + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + var req webPushUnsubscribeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Endpoint == "" { + writeError(w, http.StatusBadRequest, "bad_request", "An endpoint is required") + return + } + if err := service.Unsubscribe(r.Context(), userID, profileID, "", req.Endpoint); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove push subscription") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/router.go b/internal/api/router.go index 3b026983..b6ccff10 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -37,6 +37,7 @@ import ( "github.com/Silo-Server/silo-server/internal/intromarkers" "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/logstream" + "github.com/Silo-Server/silo-server/internal/mail" "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/metadata" @@ -119,6 +120,7 @@ type Dependencies struct { NodeID string LogStreamHub *logstream.Hub RealtimeHub *notifications.Hub + Notifications *notifications.System // user-facing release notifications (may be nil) EventsHub *evt.Hub ScanRegistry *evt.ScanRegistry LibraryScanQueue *scanqueue.Service @@ -1522,9 +1524,46 @@ func NewRouter(deps Dependencies) chi.Router { deps.LibraryScanQueue, historyImportSvc, ) + eventsHandler.SetNotificationsSystem(deps.Notifications) r.Get("/events/ws", eventsHandler.HandleWebSocket) } + // User notifications: profile-scoped inbox, preferences, and + // the websocket handshake ticket. + if deps.Notifications != nil { + if detailSvc != nil { + deps.Notifications.SetImageResolver(detailSvc) + } + notificationsHandler := handlers.NewNotificationsHandler(deps.Notifications, deps.EventsHub) + r.With(apimw.RequireProfile).Post("/events/ws-ticket", notificationsHandler.HandleMintWSTicket) + r.Route("/notifications", func(r chi.Router) { + r.Use(apimw.RequireProfile) + r.Get("/", notificationsHandler.HandleList) + r.Get("/sync", notificationsHandler.HandleSync) + r.Get("/unread-count", notificationsHandler.HandleUnreadCount) + r.Get("/capability", notificationsHandler.HandleCapability) + r.Get("/preferences", notificationsHandler.HandleGetPreferences) + r.Put("/preferences", notificationsHandler.HandleUpdatePreferences) + r.Post("/read-all", notificationsHandler.HandleReadAll) + r.Route("/webhooks", func(r chi.Router) { + r.Get("/", notificationsHandler.HandleListWebhooks) + r.Post("/", notificationsHandler.HandleCreateWebhook) + r.Put("/{id}", notificationsHandler.HandleUpdateWebhook) + r.Delete("/{id}", notificationsHandler.HandleDeleteWebhook) + r.Post("/{id}/rotate-secret", notificationsHandler.HandleRotateWebhookSecret) + r.Post("/{id}/test", notificationsHandler.HandleTestWebhook) + }) + r.Route("/web-push", func(r chi.Router) { + r.Get("/subscriptions", notificationsHandler.HandleWebPushList) + r.Post("/subscriptions", notificationsHandler.HandleWebPushSubscribe) + r.Delete("/subscriptions/{id}", notificationsHandler.HandleWebPushDelete) + r.Post("/unsubscribe", notificationsHandler.HandleWebPushUnsubscribe) + }) + r.Get("/{id}", notificationsHandler.HandleGet) + r.Post("/{id}/read", notificationsHandler.HandleMarkRead) + }) + } + // Marker read/write/clear for any authenticated viewer: users // fix and create intro/recap/credits/preview markers from the // player. Writes are stamped source="manual" and contributed to @@ -2118,6 +2157,10 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/settings/{key}", adminHandler.HandleGetSetting) r.Get("/settings", adminHandler.HandleGetSettings) r.Put("/settings/{key}", adminHandler.HandleUpdateSetting) + if settingsRepo != nil { + emailHandler := handlers.NewEmailHandler(mail.NewSMTPSender(settingsRepo)) + r.Post("/email/test", emailHandler.HandleTest) + } if adminIntroHandler != nil { r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers) r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro) diff --git a/internal/catalog/encrypted_settings_repo.go b/internal/catalog/encrypted_settings_repo.go index 30328d48..5185644f 100644 --- a/internal/catalog/encrypted_settings_repo.go +++ b/internal/catalog/encrypted_settings_repo.go @@ -91,6 +91,14 @@ var SensitiveSettingKeys = map[string]bool{ // still referenced by older request_integrations rows until backfilled). "requests.radarr.api_key": true, "requests.sonarr.api_key": true, + + // Shared outbound email (internal/mail) SMTP credential. + "email.smtp_password": true, + + // Web Push VAPID keypair JSON (generated + persisted atomically as one + // value by the notifications system; clients receive the public half via + // the capability endpoint, never from the settings store). + "notifications.web_push.vapid_keypair": true, } // EncryptedSettingsRepo decorates a raw settings store, transparently diff --git a/internal/events/types.go b/internal/events/types.go index 2f735c27..eb324e24 100644 --- a/internal/events/types.go +++ b/internal/events/types.go @@ -16,6 +16,10 @@ const ( ChannelHistoryImport EventChannel = "history_import" ChannelUserState EventChannel = "user_state" ChannelPlugins EventChannel = "plugins" + // ChannelNotifications carries profile-scoped user notifications + // (inbox deliveries). Subscriptions require a websocket ticket binding + // the connection to a (user, profile). + ChannelNotifications EventChannel = "notifications" ) var AllChannels = []EventChannel{ @@ -27,6 +31,7 @@ var AllChannels = []EventChannel{ ChannelHistoryImport, ChannelUserState, ChannelPlugins, + ChannelNotifications, } type Envelope struct { diff --git a/internal/libraryingest/executor.go b/internal/libraryingest/executor.go index bc53e73f..f078b436 100644 --- a/internal/libraryingest/executor.go +++ b/internal/libraryingest/executor.go @@ -88,6 +88,7 @@ type Executor struct { skippedRootRepo SkippedRootRepository events cache.EventBus realtime *notifications.Hub + availability *notifications.AvailabilityDetector now func() time.Time // tvDrainSettleWindow overrides scopedTVDrainSettleWindow when > 0. Kept @@ -119,6 +120,15 @@ func NewExecutor( } } +// SetAvailabilityDetector wires episode-availability detection for release +// notifications. Optional; runs after matching completes and never blocks or +// fails the ingest. +func (e *Executor) SetAvailabilityDetector(detector *notifications.AvailabilityDetector) { + if e != nil { + e.availability = detector + } +} + // IngestFolder runs the full ingest workflow for an entire library. func (e *Executor) IngestFolder(ctx context.Context, folder *models.MediaFolder) (*Result, error) { return e.ingest(ctx, folder, scopeModeLibrary, "") @@ -349,6 +359,15 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode } } + // Episode availability runs after matching/reconcile so releases are tied + // to resolved episodes. It runs detached: the detector is best-effort with + // its own deadline (it detaches from scanCtx internally, surviving its + // cancellation), and a slow pass must not delay scan completion or the + // serialized scan queue. + if e.availability != nil && (isTVLibraryType(folder.Type) || isMixedLibraryType(folder.Type)) { + go e.availability.HandleIngestCompleted(scanCtx, folder.ID, mode == scopeModeLibrary, matchScopes) + } + if shouldPublish(result) && e.events != nil { if err := e.events.Publish(scanCtx, cache.ChannelCatalog, cache.Event{ Type: cache.EventScanComplete, diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 00000000..3fda72d1 --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,240 @@ +// Package mail provides Silo's shared outbound email facility. It is +// deliberately feature-agnostic: notifications, password resets, invites, and +// any future feature send through the same Sender so SMTP configuration, +// security policy, and diagnostics live in exactly one place. +// +// Configuration is read live from server settings (no restart required): +// +// email.enabled bool, default false +// email.smtp_host hostname (required to enable) +// email.smtp_port default 587 +// email.smtp_security starttls (default) | tls | none +// email.smtp_username optional +// email.smtp_password optional; encrypted at rest (SensitiveSettingKeys) +// email.from_address required to enable +// email.from_name default "Silo" +package mail + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + gomail "github.com/wneessen/go-mail" +) + +// Server-setting keys. email.smtp_password must stay registered in +// catalog.SensitiveSettingKeys so it is encrypted at rest and redacted from +// the admin API. +const ( + SettingEnabled = "email.enabled" + SettingSMTPHost = "email.smtp_host" + SettingSMTPPort = "email.smtp_port" + SettingSMTPSecurity = "email.smtp_security" + SettingSMTPUsername = "email.smtp_username" + SettingSMTPPassword = "email.smtp_password" + SettingFromAddress = "email.from_address" + SettingFromName = "email.from_name" +) + +const sendTimeout = 30 * time.Second + +// Security modes for email.smtp_security. +const ( + securityStartTLS = "starttls" + securityTLS = "tls" + securityNone = "none" +) + +// ErrNotConfigured is returned by Send when email is disabled or incomplete. +// Callers treat email as an optional transport and degrade gracefully. +var ErrNotConfigured = errors.New("email is not configured") + +// Message is one outbound email. At least one body variant is required; when +// both are set the message is sent as multipart/alternative. +type Message struct { + To []string + Subject string + TextBody string + HTMLBody string + // ReplyTo optionally overrides the reply address. + ReplyTo string +} + +// Sender is the feature-facing abstraction. Implementations must be safe for +// concurrent use. +type Sender interface { + // Enabled reports whether email is configured and turned on, so features + // can skip composing messages that could never send. + Enabled(ctx context.Context) bool + // Send delivers one message, returning ErrNotConfigured when email is off. + Send(ctx context.Context, msg Message) error +} + +// SettingReader reads live server settings. Satisfied by +// catalog.EncryptedSettingsRepo (which transparently decrypts the password). +type SettingReader interface { + Get(ctx context.Context, key string) (string, error) +} + +// SMTPSender sends through a user-configured SMTP server. Settings are read +// on every send: email volume is low (notifications, account flows) and live +// reads mean admin changes apply without a restart. +type SMTPSender struct { + settings SettingReader +} + +// NewSMTPSender creates the shared SMTP sender. +func NewSMTPSender(settings SettingReader) *SMTPSender { + return &SMTPSender{settings: settings} +} + +type smtpConfig struct { + host string + port int + security string + username string + password string + fromAddress string + fromName string +} + +func (s *SMTPSender) loadConfig(ctx context.Context) (*smtpConfig, error) { + if s == nil || s.settings == nil { + return nil, ErrNotConfigured + } + get := func(key string) string { + value, _ := s.settings.Get(ctx, key) + return strings.TrimSpace(value) + } + if !truthy(get(SettingEnabled)) { + return nil, ErrNotConfigured + } + cfg := &smtpConfig{ + host: get(SettingSMTPHost), + port: 587, + security: strings.ToLower(get(SettingSMTPSecurity)), + username: get(SettingSMTPUsername), + password: get(SettingSMTPPassword), + fromAddress: get(SettingFromAddress), + fromName: get(SettingFromName), + } + if cfg.host == "" || cfg.fromAddress == "" { + return nil, ErrNotConfigured + } + if raw := get(SettingSMTPPort); raw != "" { + port, err := strconv.Atoi(raw) + if err != nil || port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid email.smtp_port %q", raw) + } + cfg.port = port + } + switch cfg.security { + case "": + cfg.security = securityStartTLS + case securityStartTLS, securityTLS, securityNone: + default: + return nil, fmt.Errorf("invalid email.smtp_security %q", cfg.security) + } + if cfg.fromName == "" { + cfg.fromName = "Silo" + } + return cfg, nil +} + +// Enabled reports whether email can send right now. +func (s *SMTPSender) Enabled(ctx context.Context) bool { + _, err := s.loadConfig(ctx) + return err == nil +} + +// Send delivers one message over SMTP. +func (s *SMTPSender) Send(ctx context.Context, msg Message) error { + cfg, err := s.loadConfig(ctx) + if err != nil { + return err + } + if len(msg.To) == 0 { + return errors.New("email message has no recipients") + } + if msg.TextBody == "" && msg.HTMLBody == "" { + return errors.New("email message has no body") + } + + message, err := buildMessage(cfg, msg) + if err != nil { + return err + } + client, err := newClient(cfg) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + + sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) + defer cancel() + if err := client.DialAndSendWithContext(sendCtx, message); err != nil { + return fmt.Errorf("smtp send: %w", err) + } + return nil +} + +func buildMessage(cfg *smtpConfig, msg Message) (*gomail.Msg, error) { + message := gomail.NewMsg() + if err := message.FromFormat(cfg.fromName, cfg.fromAddress); err != nil { + return nil, fmt.Errorf("invalid from address: %w", err) + } + if err := message.To(msg.To...); err != nil { + return nil, fmt.Errorf("invalid recipient: %w", err) + } + if msg.ReplyTo != "" { + if err := message.ReplyTo(msg.ReplyTo); err != nil { + return nil, fmt.Errorf("invalid reply-to address: %w", err) + } + } + message.Subject(msg.Subject) + switch { + case msg.HTMLBody != "" && msg.TextBody != "": + message.SetBodyString(gomail.TypeTextPlain, msg.TextBody) + message.AddAlternativeString(gomail.TypeTextHTML, msg.HTMLBody) + case msg.HTMLBody != "": + message.SetBodyString(gomail.TypeTextHTML, msg.HTMLBody) + default: + message.SetBodyString(gomail.TypeTextPlain, msg.TextBody) + } + return message, nil +} + +func newClient(cfg *smtpConfig) (*gomail.Client, error) { + options := []gomail.Option{ + gomail.WithPort(cfg.port), + gomail.WithTimeout(sendTimeout), + } + switch cfg.security { + case securityTLS: // implicit TLS (typically port 465) + options = append(options, gomail.WithSSL()) + case securityNone: + options = append(options, gomail.WithTLSPolicy(gomail.NoTLS)) + default: // starttls + options = append(options, gomail.WithTLSPolicy(gomail.TLSMandatory)) + } + if cfg.username != "" { + options = append(options, + gomail.WithSMTPAuth(gomail.SMTPAuthAutoDiscover), + gomail.WithUsername(cfg.username), + gomail.WithPassword(cfg.password), + ) + } + return gomail.NewClient(cfg.host, options...) +} + +func truthy(value string) bool { + switch strings.ToLower(value) { + case "true", "1", "yes", "on": + return true + default: + return false + } +} diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go new file mode 100644 index 00000000..9838ce0b --- /dev/null +++ b/internal/mail/mail_test.go @@ -0,0 +1,121 @@ +package mail + +import ( + "context" + "errors" + "strings" + "testing" +) + +type stubSettings map[string]string + +func (s stubSettings) Get(_ context.Context, key string) (string, error) { + return s[key], nil +} + +func configuredSettings() stubSettings { + return stubSettings{ + SettingEnabled: "true", + SettingSMTPHost: "smtp.example.com", + SettingFromAddress: "silo@example.com", + } +} + +func TestLoadConfigGates(t *testing.T) { + ctx := context.Background() + + t.Run("disabled by default", func(t *testing.T) { + sender := NewSMTPSender(stubSettings{}) + if sender.Enabled(ctx) { + t.Fatal("email must be disabled with no settings") + } + err := sender.Send(ctx, Message{To: []string{"a@b.c"}, Subject: "x", TextBody: "y"}) + if !errors.Is(err, ErrNotConfigured) { + t.Fatalf("Send = %v, want ErrNotConfigured", err) + } + }) + + t.Run("requires host and from address", func(t *testing.T) { + settings := configuredSettings() + settings[SettingSMTPHost] = "" + if NewSMTPSender(settings).Enabled(ctx) { + t.Fatal("missing host must disable email") + } + settings = configuredSettings() + settings[SettingFromAddress] = "" + if NewSMTPSender(settings).Enabled(ctx) { + t.Fatal("missing from address must disable email") + } + }) + + t.Run("complete config enables", func(t *testing.T) { + if !NewSMTPSender(configuredSettings()).Enabled(ctx) { + t.Fatal("complete config must enable email") + } + }) +} + +func TestLoadConfigValidation(t *testing.T) { + ctx := context.Background() + + t.Run("defaults", func(t *testing.T) { + cfg, err := NewSMTPSender(configuredSettings()).loadConfig(ctx) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + if cfg.port != 587 || cfg.security != securityStartTLS || cfg.fromName != "Silo" { + t.Fatalf("unexpected defaults: %+v", cfg) + } + }) + + t.Run("invalid port", func(t *testing.T) { + settings := configuredSettings() + settings[SettingSMTPPort] = "99999" + if _, err := NewSMTPSender(settings).loadConfig(ctx); err == nil { + t.Fatal("out-of-range port must be rejected") + } + }) + + t.Run("invalid security mode", func(t *testing.T) { + settings := configuredSettings() + settings[SettingSMTPSecurity] = "plz-hack-me" + if _, err := NewSMTPSender(settings).loadConfig(ctx); err == nil { + t.Fatal("unknown security mode must be rejected") + } + }) +} + +func TestSendInputValidation(t *testing.T) { + ctx := context.Background() + sender := NewSMTPSender(configuredSettings()) + + if err := sender.Send(ctx, Message{Subject: "x", TextBody: "y"}); err == nil { + t.Fatal("a message without recipients must be rejected") + } + if err := sender.Send(ctx, Message{To: []string{"a@b.c"}, Subject: "x"}); err == nil { + t.Fatal("a message without a body must be rejected") + } +} + +func TestBuildMessageMultipart(t *testing.T) { + cfg := &smtpConfig{fromAddress: "silo@example.com", fromName: "Silo"} + message, err := buildMessage(cfg, Message{ + To: []string{"user@example.com"}, + Subject: "Hello", + TextBody: "plain", + HTMLBody: "rich", + }) + if err != nil { + t.Fatalf("buildMessage: %v", err) + } + var rendered strings.Builder + if _, err := message.WriteTo(&rendered); err != nil { + t.Fatalf("render message: %v", err) + } + output := rendered.String() + for _, want := range []string{"multipart/alternative", "plain", "rich", "Silo", "user@example.com"} { + if !strings.Contains(output, want) { + t.Fatalf("rendered message missing %q:\n%s", want, output) + } + } +} diff --git a/internal/notifications/availability_detector.go b/internal/notifications/availability_detector.go new file mode 100644 index 00000000..c6e50161 --- /dev/null +++ b/internal/notifications/availability_detector.go @@ -0,0 +1,100 @@ +package notifications + +import ( + "context" + "log/slog" + "time" +) + +const availabilityDetectTimeout = 2 * time.Minute + +// AvailabilityDetector turns completed ingest runs into episode_availability +// facts and release events. It runs after matching/reconcile is complete so +// a release is tied to an actual resolved episode, and it never blocks or +// fails the ingest itself. +type AvailabilityDetector struct { + releases *ReleaseRepository + settings *Settings + logger *slog.Logger + // nudge wakes the fanout worker after new release events land; may be nil. + nudge func() +} + +// NewAvailabilityDetector creates an AvailabilityDetector. +func NewAvailabilityDetector(releases *ReleaseRepository, settings *Settings) *AvailabilityDetector { + return &AvailabilityDetector{ + releases: releases, + settings: settings, + logger: slog.Default().With("component", "notifications.availability"), + } +} + +// SetFanoutNudge wires the fanout worker wake signal. +func (d *AvailabilityDetector) SetFanoutNudge(nudge func()) { + if d != nil { + d.nudge = nudge + } +} + +// HandleIngestCompleted records newly available episodes for a completed +// ingest scope. fullLibrary distinguishes whole-library scans (set-based +// detection, and the scan that seeds a new library) from subtree/file scans +// (path-bounded detection). +// +// Seeding semantics: a library without a seed marker records availability +// silently — "newly available" means newly released to this server, not newly +// seen by the notifications feature. The marker is written when a full scan +// completes successfully, so the next scan onward emits release events. +func (d *AvailabilityDetector) HandleIngestCompleted(ctx context.Context, libraryID int, fullLibrary bool, scopePaths []string) { + if d == nil || d.releases == nil { + return + } + // The scan context is done once the scan finishes; detection runs on its + // own deadline so cancellation of the parent does not drop availability. + detectCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), availabilityDetectTimeout) + defer cancel() + + seeded, err := d.releases.IsLibrarySeeded(detectCtx, libraryID) + if err != nil { + d.logger.Warn("seed state lookup failed", "library_id", libraryID, "error", err) + return + } + emitEvents := seeded && d.settings.ReleaseEventsEnabled(detectCtx) + + var inserted, events int + if fullLibrary { + inserted, events, err = d.releases.RecordAvailabilityForLibrary(detectCtx, libraryID, emitEvents) + } else if seeded { + inserted, events, err = d.releases.RecordAvailabilityForPaths(detectCtx, libraryID, scopePaths, emitEvents) + } else { + // Subtree/file ingest on an unseeded library: record silently but do + // not seed-mark — only a successful full scan proves the back catalog + // has been captured. + inserted, events, err = d.releases.RecordAvailabilityForPaths(detectCtx, libraryID, scopePaths, false) + } + if err != nil { + d.logger.Warn("availability detection failed", "library_id", libraryID, "error", err) + return + } + + if fullLibrary && !seeded { + if err := d.releases.MarkLibrarySeeded(detectCtx, libraryID); err != nil { + d.logger.Warn("seed marker write failed", "library_id", libraryID, "error", err) + } else { + d.logger.Info("library availability seeded", + "library_id", libraryID, "availability_rows", inserted) + } + } + + if inserted > 0 || events > 0 { + d.logger.Info("availability recorded", + "library_id", libraryID, + "full_library", fullLibrary, + "availability_rows", inserted, + "release_events", events, + ) + } + if events > 0 && d.nudge != nil { + d.nudge() + } +} diff --git a/internal/notifications/delivery_repo.go b/internal/notifications/delivery_repo.go new file mode 100644 index 00000000..2cd5fc57 --- /dev/null +++ b/internal/notifications/delivery_repo.go @@ -0,0 +1,335 @@ +package notifications + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// DeliveryRepository owns notification_deliveries. +type DeliveryRepository struct { + pool *pgxpool.Pool +} + +// NewDeliveryRepository creates a DeliveryRepository. +func NewDeliveryRepository(pool *pgxpool.Pool) *DeliveryRepository { + return &DeliveryRepository{pool: pool} +} + +// Cursor is an opaque pagination cursor over (created_at, id). +type Cursor struct { + CreatedAt time.Time + ID string +} + +// Encode returns the opaque wire form of the cursor. +func (c Cursor) Encode() string { + raw := c.CreatedAt.UTC().Format(time.RFC3339Nano) + "|" + c.ID + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +// DecodeCursor parses an opaque cursor produced by Encode. +func DecodeCursor(encoded string) (Cursor, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return Cursor{}, errors.New("invalid cursor") + } + parts := strings.SplitN(string(raw), "|", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return Cursor{}, errors.New("invalid cursor") + } + createdAt, err := time.Parse(time.RFC3339Nano, parts[0]) + if err != nil { + return Cursor{}, errors.New("invalid cursor") + } + return Cursor{CreatedAt: createdAt, ID: parts[1]}, nil +} + +// deliveryRowSelect joins display metadata so clients can render a row +// without an extra lookup. LEFT JOINs keep operational delivery types (no +// episode/series) and deleted catalog rows renderable. +const deliveryRowSelect = ` + SELECT d.id, d.release_event_id, d.user_id, d.profile_id, d.library_id, d.series_id, d.episode_id, + d.type, d.reason_flags, d.status, d.read_at, d.delivered_at, d.created_at, + COALESCE(s.title, '') AS series_title, + COALESCE(e.title, '') AS episode_title, + e.season_number, e.episode_number, + COALESCE(s.poster_path, '') AS poster_path, + COALESCE(s.poster_thumbhash, '') AS poster_thumbhash + FROM notification_deliveries d + LEFT JOIN episodes e ON e.content_id = d.episode_id + LEFT JOIN media_items s ON s.content_id = d.series_id` + +func scanDeliveryRows(rows pgx.Rows) ([]DeliveryRow, error) { + defer rows.Close() + out := make([]DeliveryRow, 0, 25) + for rows.Next() { + var row DeliveryRow + if err := rows.Scan( + &row.ID, &row.ReleaseEventID, &row.UserID, &row.ProfileID, + &row.LibraryID, &row.SeriesID, &row.EpisodeID, + &row.Type, &row.ReasonFlags, &row.Status, &row.ReadAt, &row.DeliveredAt, &row.CreatedAt, + &row.SeriesTitle, &row.EpisodeTitle, &row.SeasonNumber, &row.EpisodeNumber, + &row.PosterPath, &row.PosterThumbhash, + ); err != nil { + return nil, fmt.Errorf("scan delivery row: %w", err) + } + out = append(out, row) + } + return out, rows.Err() +} + +// BulkInsert inserts deliveries with ON CONFLICT DO NOTHING (both partial +// uniques participate: per-release-event and cross-library per-episode) and +// returns only the rows actually inserted. Realtime publish and channel +// dispatch must operate on the returned set, never the candidate set. +func (r *DeliveryRepository) BulkInsert(ctx context.Context, tx pgx.Tx, deliveries []Delivery) ([]InsertedDelivery, error) { + const chunkSize = 500 + inserted := make([]InsertedDelivery, 0, len(deliveries)) + for start := 0; start < len(deliveries); start += chunkSize { + end := min(start+chunkSize, len(deliveries)) + chunk := deliveries[start:end] + + var sb strings.Builder + sb.WriteString(` + INSERT INTO notification_deliveries + (id, release_event_id, user_id, profile_id, library_id, series_id, episode_id, + type, reason_flags, status, delivered_at) + VALUES `) + args := make([]any, 0, len(chunk)*11) + for i, delivery := range chunk { + if i > 0 { + sb.WriteString(", ") + } + base := len(args) + sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)", + base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10, base+11)) + status := delivery.Status + if status == "" { + status = "delivered" + } + args = append(args, + delivery.ID, delivery.ReleaseEventID, delivery.UserID, delivery.ProfileID, + delivery.LibraryID, delivery.SeriesID, delivery.EpisodeID, + delivery.Type, delivery.ReasonFlags, status, time.Now().UTC(), + ) + } + sb.WriteString(" ON CONFLICT DO NOTHING RETURNING id, user_id, profile_id, created_at") + + rows, err := tx.Query(ctx, sb.String(), args...) + if err != nil { + return nil, fmt.Errorf("bulk insert deliveries: %w", err) + } + for rows.Next() { + var row InsertedDelivery + if err := rows.Scan(&row.ID, &row.UserID, &row.ProfileID, &row.CreatedAt); err != nil { + rows.Close() + return nil, fmt.Errorf("scan inserted delivery: %w", err) + } + inserted = append(inserted, row) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read inserted deliveries: %w", err) + } + } + return inserted, nil +} + +// ListInbox returns inbox rows newest-first for the profile. +func (r *DeliveryRepository) ListInbox(ctx context.Context, profileID string, unreadOnly bool, limit int, before *Cursor) ([]DeliveryRow, error) { + conditions := []string{"d.profile_id = $1"} + args := []any{profileID} + if unreadOnly { + conditions = append(conditions, "d.read_at IS NULL") + } + if before != nil { + args = append(args, before.CreatedAt, before.ID) + conditions = append(conditions, fmt.Sprintf("(d.created_at, d.id) < ($%d, $%d)", len(args)-1, len(args))) + } + args = append(args, limit) + query := deliveryRowSelect + + " WHERE " + strings.Join(conditions, " AND ") + + fmt.Sprintf(" ORDER BY d.created_at DESC, d.id DESC LIMIT $%d", len(args)) + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list inbox: %w", err) + } + return scanDeliveryRows(rows) +} + +// ListSync returns rows ascending from the cursor for forward sync (the +// mobile wake-fetch endpoint). A nil cursor returns the most recent page +// (still ascending) so first-time callers get a cursor to persist. +func (r *DeliveryRepository) ListSync(ctx context.Context, profileID string, since *Cursor, limit int) ([]DeliveryRow, error) { + if since != nil { + rows, err := r.pool.Query(ctx, + deliveryRowSelect+` + WHERE d.profile_id = $1 AND (d.created_at, d.id) > ($2, $3) + ORDER BY d.created_at ASC, d.id ASC + LIMIT $4`, + profileID, since.CreatedAt, since.ID, limit) + if err != nil { + return nil, fmt.Errorf("list sync: %w", err) + } + return scanDeliveryRows(rows) + } + // No cursor: most recent page, returned in ascending order. + rows, err := r.pool.Query(ctx, ` + SELECT * FROM (`+deliveryRowSelect+` + WHERE d.profile_id = $1 + ORDER BY d.created_at DESC, d.id DESC + LIMIT $2 + ) recent ORDER BY recent.created_at ASC, recent.id ASC`, + profileID, limit) + if err != nil { + return nil, fmt.Errorf("list sync: %w", err) + } + return scanDeliveryRows(rows) +} + +// GetByID returns one delivery scoped to the profile; (nil, nil) when absent. +func (r *DeliveryRepository) GetByID(ctx context.Context, profileID, id string) (*DeliveryRow, error) { + rows, err := r.pool.Query(ctx, + deliveryRowSelect+` WHERE d.profile_id = $1 AND d.id = $2`, profileID, id) + if err != nil { + return nil, fmt.Errorf("get delivery: %w", err) + } + out, err := scanDeliveryRows(rows) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, nil + } + return &out[0], nil +} + +// GetRowByID loads one delivery without profile scoping. Internal use only +// (webhook attempt processing); API paths must use GetByID. +func (r *DeliveryRepository) GetRowByID(ctx context.Context, id string) (*DeliveryRow, error) { + rows, err := r.pool.Query(ctx, deliveryRowSelect+` WHERE d.id = $1`, id) + if err != nil { + return nil, fmt.Errorf("get delivery row: %w", err) + } + out, err := scanDeliveryRows(rows) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, nil + } + return &out[0], nil +} + +// InsertOperational inserts a single operational delivery (e.g. +// webhook.auto_disabled) outside the fanout path. Returns nil when the row +// deduped away. +func (r *DeliveryRepository) InsertOperational(ctx context.Context, delivery Delivery) (*InsertedDelivery, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin operational delivery tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + inserted, err := r.BulkInsert(ctx, tx, []Delivery{delivery}) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit operational delivery: %w", err) + } + if len(inserted) == 0 { + return nil, nil + } + return &inserted[0], nil +} + +// RecentUnread returns the newest unread rows for the websocket snapshot. +func (r *DeliveryRepository) RecentUnread(ctx context.Context, profileID string, limit int) ([]DeliveryRow, error) { + rows, err := r.pool.Query(ctx, + deliveryRowSelect+` + WHERE d.profile_id = $1 AND d.read_at IS NULL + ORDER BY d.created_at DESC, d.id DESC + LIMIT $2`, + profileID, limit) + if err != nil { + return nil, fmt.Errorf("recent unread: %w", err) + } + return scanDeliveryRows(rows) +} + +// UnreadCount returns the unread badge count for the profile. +func (r *DeliveryRepository) UnreadCount(ctx context.Context, profileID string) (int, error) { + var count int + err := r.pool.QueryRow(ctx, + `SELECT count(*) FROM notification_deliveries WHERE profile_id = $1 AND read_at IS NULL`, + profileID, + ).Scan(&count) + return count, err +} + +// MarkRead marks one delivery read. Idempotent; reports whether the row +// transitioned from unread to read. +func (r *DeliveryRepository) MarkRead(ctx context.Context, profileID, id string) (bool, error) { + tag, err := r.pool.Exec(ctx, ` + UPDATE notification_deliveries + SET read_at = now() + WHERE profile_id = $1 AND id = $2 AND read_at IS NULL`, + profileID, id) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// Exists reports whether a delivery belongs to the profile (used to make +// mark-read idempotent without leaking other profiles' IDs). +func (r *DeliveryRepository) Exists(ctx context.Context, profileID, id string) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM notification_deliveries WHERE profile_id = $1 AND id = $2)`, + profileID, id, + ).Scan(&exists) + return exists, err +} + +// MarkAllRead marks every unread delivery read for the profile. +func (r *DeliveryRepository) MarkAllRead(ctx context.Context, profileID string) (int64, error) { + tag, err := r.pool.Exec(ctx, ` + UPDATE notification_deliveries + SET read_at = now() + WHERE profile_id = $1 AND read_at IS NULL`, + profileID) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// DeleteAllForProfile removes every delivery for a deleted profile (profiles +// may live outside Postgres, so no cascade). +func (r *DeliveryRepository) DeleteAllForProfile(ctx context.Context, profileID string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM notification_deliveries WHERE profile_id = $1`, profileID) + return err +} + +// DeleteOld applies retention: read rows past readCutoff, unread rows past +// unreadCutoff. +func (r *DeliveryRepository) DeleteOld(ctx context.Context, readCutoff, unreadCutoff time.Time) (int64, error) { + tag, err := r.pool.Exec(ctx, ` + DELETE FROM notification_deliveries + WHERE (read_at IS NOT NULL AND created_at < $1) + OR (read_at IS NULL AND created_at < $2)`, + readCutoff, unreadCutoff) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/internal/notifications/dispatcher.go b/internal/notifications/dispatcher.go new file mode 100644 index 00000000..69e386c2 --- /dev/null +++ b/internal/notifications/dispatcher.go @@ -0,0 +1,136 @@ +package notifications + +import ( + "context" + "encoding/json" + "time" + + evt "github.com/Silo-Server/silo-server/internal/events" +) + +// EventNotificationCreated is published on ChannelNotifications when a new +// delivery is created; EventNotificationRead when one is marked read +// (multi-tab coherence). +const ( + EventNotificationCreated = "notification.created" + EventNotificationRead = "notification.read" +) + +// Dispatcher fans one committed notification delivery out to a channel. +// Called once per notification_deliveries row, AFTER the row commits. The +// websocket dispatcher is idempotent by delivery_id alone (re-publishing the +// same delivery is a no-op for connected clients); per-target channels (push, +// webhooks — see specs 02-04) claim durable `pending` outbox attempt rows +// enqueued in the fanout transaction instead of deciding their own work. +type Dispatcher interface { + Dispatch(ctx context.Context, delivery DeliveryRow) error +} + +// DeliveryRowPayload is the JSON shape shared by the inbox API, the websocket +// snapshot, and notification.created events. Keep these in lockstep. +type DeliveryRowPayload struct { + ID string `json:"id"` + Type string `json:"type"` + ProfileID string `json:"profile_id"` + LibraryID *int `json:"library_id,omitempty"` + SeriesID *string `json:"series_id,omitempty"` + EpisodeID *string `json:"episode_id,omitempty"` + SeriesTitle string `json:"series_title,omitempty"` + EpisodeTitle string `json:"episode_title,omitempty"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` + PosterPath string `json:"poster_path,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + ReasonFlags json.RawMessage `json:"reason_flags"` + CreatedAt time.Time `json:"created_at"` + ReadAt *time.Time `json:"read_at"` +} + +// PayloadForRow converts a DeliveryRow into its wire shape. +func PayloadForRow(row DeliveryRow) DeliveryRowPayload { + reasonFlags := json.RawMessage(row.ReasonFlags) + if len(reasonFlags) == 0 { + reasonFlags = json.RawMessage("{}") + } + return DeliveryRowPayload{ + ID: row.ID, + Type: row.Type, + ProfileID: row.ProfileID, + LibraryID: row.LibraryID, + SeriesID: row.SeriesID, + EpisodeID: row.EpisodeID, + SeriesTitle: row.SeriesTitle, + EpisodeTitle: row.EpisodeTitle, + SeasonNumber: row.SeasonNumber, + EpisodeNumber: row.EpisodeNumber, + PosterPath: row.PosterPath, + PosterThumbhash: row.PosterThumbhash, + ReasonFlags: reasonFlags, + CreatedAt: row.CreatedAt, + ReadAt: row.ReadAt, + } +} + +// WebsocketDispatcher publishes notification.created on ChannelNotifications, +// scoped to the delivery's (user_id, profile_id). Best-effort: the durable +// inbox row is the source of truth and covers reconnect. +type WebsocketDispatcher struct { + hub *evt.Hub + // payload overrides the default PayloadForRow conversion (e.g. to attach + // presigned poster URLs). Optional. + payload func(ctx context.Context, row DeliveryRow) DeliveryRowPayload +} + +// NewWebsocketDispatcher creates a WebsocketDispatcher. +func NewWebsocketDispatcher(hub *evt.Hub) *WebsocketDispatcher { + return &WebsocketDispatcher{hub: hub} +} + +// Dispatch publishes the delivery to connected clients. +func (d *WebsocketDispatcher) Dispatch(ctx context.Context, delivery DeliveryRow) error { + if d == nil || d.hub == nil { + return nil + } + payload := PayloadForRow(delivery) + if d.payload != nil { + payload = d.payload(ctx, delivery) + } + return d.hub.PublishJSON(ctx, evt.ChannelNotifications, EventNotificationCreated, + payload, evt.PublishOptions{ + UserID: delivery.UserID, + ProfileID: delivery.ProfileID, + }) +} + +// MultiDispatcher runs all configured dispatchers; channel failures are +// isolated so a downed channel never blocks the others. +type MultiDispatcher struct { + dispatchers []Dispatcher +} + +// NewMultiDispatcher creates a MultiDispatcher over the given channels. +func NewMultiDispatcher(dispatchers ...Dispatcher) *MultiDispatcher { + out := make([]Dispatcher, 0, len(dispatchers)) + for _, d := range dispatchers { + if d != nil { + out = append(out, d) + } + } + return &MultiDispatcher{dispatchers: out} +} + +// Dispatch fans the delivery to every channel, returning the first error +// (after attempting all channels). +func (m *MultiDispatcher) Dispatch(ctx context.Context, delivery DeliveryRow) error { + if m == nil { + return nil + } + var firstErr error + for _, d := range m.dispatchers { + if err := d.Dispatch(ctx, delivery); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/internal/notifications/episode_key.go b/internal/notifications/episode_key.go new file mode 100644 index 00000000..29b07f9c --- /dev/null +++ b/internal/notifications/episode_key.go @@ -0,0 +1,31 @@ +package notifications + +import "math" + +// episodeKeySeasonMultiplier folds (season, episode) ordinals into a single +// sortable integer key. The 1,000,000 multiplier accommodates absolute- +// numbered anime catalogs (10,000+ episodes flattened into one season) while +// keeping the combined value inside PostgreSQL's integer range for any +// realistic season number. Scanners must not emit episode numbers at or above +// the multiplier; ValidEpisodeOrdinals rejects such rows at ingest. +const episodeKeySeasonMultiplier = 1_000_000 + +// episodeKeyMaxSeason is the largest season number whose key still fits in a +// PostgreSQL int4 (episode_key columns). Higher values come from mis-parsed +// metadata (e.g. date-style season folders) and must be excluded everywhere a +// key is computed, in Go and in SQL alike. +const episodeKeyMaxSeason = (math.MaxInt32 - (episodeKeySeasonMultiplier - 1)) / episodeKeySeasonMultiplier + +// EpisodeKey returns the canonical progression key for an episode. Every +// component that stores or compares episode progression and release state +// must use this helper so keys stay mutually comparable. +func EpisodeKey(seasonNumber, episodeNumber int) int { + return seasonNumber*episodeKeySeasonMultiplier + episodeNumber +} + +// ValidEpisodeOrdinals reports whether the ordinals can be folded into an +// episode key that fits in an int4 without collisions. +func ValidEpisodeOrdinals(seasonNumber, episodeNumber int) bool { + return seasonNumber >= 0 && seasonNumber <= episodeKeyMaxSeason && + episodeNumber >= 0 && episodeNumber < episodeKeySeasonMultiplier +} diff --git a/internal/notifications/fanout_logic.go b/internal/notifications/fanout_logic.go new file mode 100644 index 00000000..7edb2fff --- /dev/null +++ b/internal/notifications/fanout_logic.go @@ -0,0 +1,77 @@ +package notifications + +import ( + "sort" +) + +// EvaluateRecipient applies the eligibility rules for one candidate recipient +// of one release event. Returns the matched reason flags and whether a +// delivery should be created. +// +// Rules (docs/superpowers/plans/notifications/01, "Eligibility Rules"): +// - favorite / watchlist / continue_watching notify on any newly available +// episode of the series +// - next_up notifies only when the episode is at or beyond the profile's +// next_expected_episode_key +// - suppress when last_notified_episode_key >= episode_key +// - profile preferences are a hard gate: disabled reasons cannot match +func EvaluateRecipient(interest SeriesInterest, prefs Preferences, episodeKey int) (ReasonFlags, bool) { + if !prefs.Enabled { + return ReasonFlags{}, false + } + if interest.LastNotifiedEpisodeKey != nil && *interest.LastNotifiedEpisodeKey >= episodeKey { + return ReasonFlags{}, false + } + flags := ReasonFlags{ + Favorite: interest.Favorite && prefs.NotifyFavorites, + Watchlist: interest.Watchlist && prefs.NotifyWatchlist, + ContinueWatching: interest.ContinueWatching && prefs.NotifyContinueWatching, + NextUp: interest.NextUpCandidate && prefs.NotifyNextUp && + interest.NextExpectedEpisodeKey != nil && episodeKey >= *interest.NextExpectedEpisodeKey, + } + return flags, flags.Any() +} + +// ApplyBurstCap groups claimed events by (library, series) and bounds fanout +// per group: only the maxPerSeries events with the highest episode_key fan +// out; the rest are suppressed (processed without deliveries). This bounds +// the blast radius of bulk additions (back-catalog season packs) to seeded +// libraries. The cap is per claim batch and therefore approximate across +// batches, which is acceptable. +func ApplyBurstCap(events []ReleaseEvent, maxPerSeries int) (fanout, suppressed []ReleaseEvent) { + if maxPerSeries <= 0 { + maxPerSeries = 1 + } + type groupKey struct { + libraryID int + seriesID string + } + groups := make(map[groupKey][]ReleaseEvent) + order := make([]groupKey, 0) + for _, event := range events { + key := groupKey{event.LibraryID, event.SeriesID} + if _, ok := groups[key]; !ok { + order = append(order, key) + } + groups[key] = append(groups[key], event) + } + + fanout = make([]ReleaseEvent, 0, len(events)) + suppressed = make([]ReleaseEvent, 0) + for _, key := range order { + group := groups[key] + sort.Slice(group, func(i, j int) bool { + return group[i].EpisodeKey > group[j].EpisodeKey + }) + keep := min(maxPerSeries, len(group)) + suppressed = append(suppressed, group[keep:]...) + // Emit the kept events in ascending key order: fanout raises + // last_notified_episode_key as it processes each event, so a higher + // key processed first would make EvaluateRecipient suppress every + // remaining lower-key event in the group. + for i := keep - 1; i >= 0; i-- { + fanout = append(fanout, group[i]) + } + } + return fanout, suppressed +} diff --git a/internal/notifications/fanout_logic_test.go b/internal/notifications/fanout_logic_test.go new file mode 100644 index 00000000..db54d635 --- /dev/null +++ b/internal/notifications/fanout_logic_test.go @@ -0,0 +1,213 @@ +package notifications + +import ( + "context" + "testing" + "time" +) + +func intPtr(v int) *int { return &v } + +func TestEpisodeKey(t *testing.T) { + if got := EpisodeKey(2, 1); got != 2_000_001 { + t.Fatalf("EpisodeKey(2,1) = %d, want 2000001", got) + } + if got := EpisodeKey(0, 0); got != 0 { + t.Fatalf("EpisodeKey(0,0) = %d, want 0", got) + } + // Absolute-numbered anime catalogs exceed 10k episodes in one season. + if EpisodeKey(1, 11000) <= EpisodeKey(1, 10999) { + t.Fatal("episode keys must stay ordered for large episode numbers") + } + if EpisodeKey(2, 0) <= EpisodeKey(1, 999_999) { + t.Fatal("season boundary must dominate any in-season episode number") + } +} + +func TestValidEpisodeOrdinals(t *testing.T) { + cases := []struct { + season, episode int + want bool + }{ + {0, 0, true}, + {1, 999_999, true}, + {1, 1_000_000, false}, + {-1, 1, false}, + {1, -1, false}, + } + for _, tc := range cases { + if got := ValidEpisodeOrdinals(tc.season, tc.episode); got != tc.want { + t.Errorf("ValidEpisodeOrdinals(%d,%d) = %v, want %v", tc.season, tc.episode, got, tc.want) + } + } +} + +func TestEvaluateRecipientReasons(t *testing.T) { + prefs := DefaultPreferences("p1") + episodeKey := EpisodeKey(2, 5) + + t.Run("favorite matches", func(t *testing.T) { + flags, ok := EvaluateRecipient(SeriesInterest{Favorite: true}, prefs, episodeKey) + if !ok || !flags.Favorite || flags.Watchlist || flags.NextUp { + t.Fatalf("unexpected flags %+v ok=%v", flags, ok) + } + }) + + t.Run("multiple reasons merge", func(t *testing.T) { + interest := SeriesInterest{ + Favorite: true, + ContinueWatching: true, + NextUpCandidate: true, + NextExpectedEpisodeKey: intPtr(episodeKey), + } + flags, ok := EvaluateRecipient(interest, prefs, episodeKey) + if !ok || !flags.Favorite || !flags.ContinueWatching || !flags.NextUp { + t.Fatalf("unexpected flags %+v ok=%v", flags, ok) + } + }) + + t.Run("next up gated by cursor", func(t *testing.T) { + interest := SeriesInterest{ + NextUpCandidate: true, + NextExpectedEpisodeKey: intPtr(episodeKey + 1), + } + if _, ok := EvaluateRecipient(interest, prefs, episodeKey); ok { + t.Fatal("episode below next_expected must not notify via next_up") + } + interest.NextExpectedEpisodeKey = intPtr(episodeKey) + if _, ok := EvaluateRecipient(interest, prefs, episodeKey); !ok { + t.Fatal("episode at next_expected must notify via next_up") + } + }) + + t.Run("last notified suppresses repeats and older keys", func(t *testing.T) { + interest := SeriesInterest{Favorite: true, LastNotifiedEpisodeKey: intPtr(episodeKey)} + if _, ok := EvaluateRecipient(interest, prefs, episodeKey); ok { + t.Fatal("already-notified key must suppress") + } + if _, ok := EvaluateRecipient(interest, prefs, episodeKey-1); ok { + t.Fatal("older key must suppress") + } + if _, ok := EvaluateRecipient(interest, prefs, episodeKey+1); !ok { + t.Fatal("newer key must notify") + } + }) + + t.Run("preferences are a hard gate", func(t *testing.T) { + disabled := DefaultPreferences("p1") + disabled.NotifyFavorites = false + if _, ok := EvaluateRecipient(SeriesInterest{Favorite: true}, disabled, episodeKey); ok { + t.Fatal("disabled reason must not produce a delivery") + } + killSwitch := DefaultPreferences("p1") + killSwitch.Enabled = false + interest := SeriesInterest{Favorite: true, Watchlist: true, ContinueWatching: true} + if _, ok := EvaluateRecipient(interest, killSwitch, episodeKey); ok { + t.Fatal("master toggle must suppress everything") + } + }) +} + +func TestApplyBurstCap(t *testing.T) { + event := func(library int, series string, key int) ReleaseEvent { + return ReleaseEvent{ + ID: series + "-" + time.Duration(key).String(), + LibraryID: library, + SeriesID: series, + EpisodeKey: key, + } + } + + t.Run("caps per series keeping highest keys", func(t *testing.T) { + events := []ReleaseEvent{ + event(1, "a", 1), event(1, "a", 2), event(1, "a", 3), event(1, "a", 4), event(1, "a", 5), + event(1, "b", 10), + } + fanout, suppressed := ApplyBurstCap(events, 3) + if len(fanout) != 4 || len(suppressed) != 2 { + t.Fatalf("got %d fanned out, %d suppressed; want 4/2", len(fanout), len(suppressed)) + } + for _, ev := range suppressed { + if ev.SeriesID != "a" || ev.EpisodeKey > 2 { + t.Fatalf("suppressed wrong event: %+v", ev) + } + } + }) + + t.Run("distinct libraries are distinct groups", func(t *testing.T) { + events := []ReleaseEvent{ + event(1, "a", 1), event(1, "a", 2), + event(2, "a", 1), event(2, "a", 2), + } + fanout, suppressed := ApplyBurstCap(events, 2) + if len(fanout) != 4 || len(suppressed) != 0 { + t.Fatalf("got %d/%d; same series in two libraries must not share a cap", len(fanout), len(suppressed)) + } + }) + + t.Run("under cap passes through", func(t *testing.T) { + events := []ReleaseEvent{event(1, "a", 1), event(1, "b", 2)} + fanout, suppressed := ApplyBurstCap(events, 3) + if len(fanout) != 2 || len(suppressed) != 0 { + t.Fatalf("got %d/%d; want 2/0", len(fanout), len(suppressed)) + } + }) + + t.Run("kept events emit in ascending key order", func(t *testing.T) { + // Fanout raises last_notified_episode_key as it processes each event + // inside one transaction; emitting a higher key first would make + // EvaluateRecipient suppress every remaining event in the group. + events := []ReleaseEvent{ + event(1, "a", 5), event(1, "a", 3), event(1, "a", 4), event(1, "a", 1), + } + fanout, suppressed := ApplyBurstCap(events, 3) + if len(fanout) != 3 || len(suppressed) != 1 { + t.Fatalf("got %d/%d; want 3/1", len(fanout), len(suppressed)) + } + if fanout[0].EpisodeKey != 3 || fanout[1].EpisodeKey != 4 || fanout[2].EpisodeKey != 5 { + t.Fatalf("want keys [3 4 5] in order, got %+v", fanout) + } + if suppressed[0].EpisodeKey != 1 { + t.Fatalf("want lowest key suppressed, got %+v", suppressed[0]) + } + }) +} + +func TestCursorRoundTrip(t *testing.T) { + cursor := Cursor{CreatedAt: time.Date(2026, 6, 11, 10, 30, 0, 123456789, time.UTC), ID: "01ABC"} + decoded, err := DecodeCursor(cursor.Encode()) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if !decoded.CreatedAt.Equal(cursor.CreatedAt) || decoded.ID != cursor.ID { + t.Fatalf("round trip mismatch: %+v vs %+v", decoded, cursor) + } + if _, err := DecodeCursor("not-a-cursor"); err == nil { + t.Fatal("garbage cursor must fail to decode") + } + if _, err := DecodeCursor(""); err == nil { + t.Fatal("empty cursor must fail to decode") + } +} + +func TestMemoryTicketStore(t *testing.T) { + store := NewTicketStore(nil) + ctx := context.Background() + + ticket, ttl, err := store.Mint(ctx, 7, "profile-1") + if err != nil || ticket == "" || ttl <= 0 { + t.Fatalf("mint failed: %q %v %v", ticket, ttl, err) + } + + userID, profileID, ok := store.Consume(ctx, ticket) + if !ok || userID != 7 || profileID != "profile-1" { + t.Fatalf("consume returned %d %q %v", userID, profileID, ok) + } + + if _, _, ok := store.Consume(ctx, ticket); ok { + t.Fatal("tickets must be single-use") + } + if _, _, ok := store.Consume(ctx, "unknown"); ok { + t.Fatal("unknown tickets must be rejected") + } +} diff --git a/internal/notifications/fanout_worker.go b/internal/notifications/fanout_worker.go new file mode 100644 index 00000000..de8a7602 --- /dev/null +++ b/internal/notifications/fanout_worker.go @@ -0,0 +1,452 @@ +package notifications + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/oklog/ulid/v2" +) + +const ( + fanoutPollInterval = 15 * time.Second + fanoutClaimLimit = 100 +) + +// FanoutWorker consumes unprocessed release_events and materializes +// notification_deliveries plus realtime dispatch. It is safe to run on +// multiple nodes: claiming uses FOR UPDATE SKIP LOCKED, delivery inserts are +// idempotent, and the last-notified cursor update is guarded. +type FanoutWorker struct { + pool *pgxpool.Pool + releases *ReleaseRepository + interests *InterestRepository + deliveries *DeliveryRepository + preferences *PreferencesRepository + settings *Settings + dispatcher Dispatcher + logger *slog.Logger + nudge chan struct{} + + // Per-target outboxes: when set, the fanout transaction also enqueues + // `pending` attempt rows for each newly inserted delivery — one per + // enabled, reason-matching webhook and one per enabled web push + // subscription. + webhooks *WebhookRepository + rateLimiter *profileRateLimiter + webPush *WebPushRepository +} + +// SetWebhookOutbox wires durable webhook attempt enqueueing into the fanout +// transaction. +func (w *FanoutWorker) SetWebhookOutbox(webhooks *WebhookRepository, limiter *profileRateLimiter) { + w.webhooks = webhooks + w.rateLimiter = limiter +} + +// SetWebPushOutbox wires durable web push attempt enqueueing into the fanout +// transaction. +func (w *FanoutWorker) SetWebPushOutbox(webPush *WebPushRepository) { + w.webPush = webPush +} + +// NewFanoutWorker creates a FanoutWorker. +func NewFanoutWorker( + pool *pgxpool.Pool, + releases *ReleaseRepository, + interests *InterestRepository, + deliveries *DeliveryRepository, + preferences *PreferencesRepository, + settings *Settings, + dispatcher Dispatcher, +) *FanoutWorker { + return &FanoutWorker{ + pool: pool, + releases: releases, + interests: interests, + deliveries: deliveries, + preferences: preferences, + settings: settings, + dispatcher: dispatcher, + logger: slog.Default().With("component", "notifications.fanout"), + nudge: make(chan struct{}, 1), + } +} + +// Nudge schedules a near-term claim pass (after the settle delay) so +// notifications feel realtime without tight polling. Non-blocking. +func (w *FanoutWorker) Nudge() { + if w == nil { + return + } + select { + case w.nudge <- struct{}{}: + default: + } +} + +// Run processes release events until ctx is canceled. +func (w *FanoutWorker) Run(ctx context.Context) { + ticker := time.NewTicker(fanoutPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-w.nudge: + // Wait out the settle delay so one scan's burst for a series + // lands in a single claim batch instead of trickling through. + settle := w.settings.SettleDelay(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(settle + time.Second): + } + } + if !w.settings.FanoutEnabled(ctx) { + continue + } + for { + processed, err := w.processBatch(ctx) + if err != nil { + if ctx.Err() == nil { + w.logger.Error("fanout batch failed", "error", err) + } + break + } + if processed == 0 { + break + } + } + } +} + +// processBatch claims one batch of release events and fans them out. The +// claim, delivery inserts, cursor updates, and processed marks share one +// transaction so an event is never marked processed without durable +// deliveries; reprocessing after a crash is harmless because delivery inserts +// dedupe. Returns the number of events handled (fanned out + suppressed). +func (w *FanoutWorker) processBatch(ctx context.Context) (int, error) { + started := time.Now() + settle := w.settings.SettleDelay(ctx) + maxBurst := w.settings.MaxSeriesBurst(ctx) + + tx, err := w.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin fanout tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + events, err := w.releases.ClaimUnprocessed(ctx, tx, settle, fanoutClaimLimit) + if err != nil { + return 0, err + } + if len(events) == 0 { + return 0, nil + } + + // Suppress events that aged past the staleness horizon before fanout + // could run (fanout disabled for a stretch, extended downtime): users + // should not receive a backlog of long-stale "new episode" notifications + // when the worker comes back. + staleCutoff := time.Now().Add(-w.settings.MaxEventAge(ctx)) + fresh := make([]ReleaseEvent, 0, len(events)) + stale := make([]ReleaseEvent, 0) + for _, event := range events { + if event.CreatedAt.Before(staleCutoff) { + stale = append(stale, event) + } else { + fresh = append(fresh, event) + } + } + if len(stale) > 0 { + reason := SuppressedReasonStale + if err := w.releases.MarkProcessed(ctx, tx, eventIDs(stale), &reason); err != nil { + return 0, err + } + } + + fanout, suppressed := ApplyBurstCap(fresh, maxBurst) + if len(suppressed) > 0 { + reason := SuppressedReasonSeriesBurst + if err := w.releases.MarkProcessed(ctx, tx, eventIDs(suppressed), &reason); err != nil { + return 0, err + } + } + + totalRecipients := 0 + totalInserted := 0 + dispatchRows := make([]DeliveryRow, 0, 32) + for _, event := range fanout { + rows, recipients, err := w.fanOutEvent(ctx, tx, event) + if err != nil { + return 0, fmt.Errorf("fan out event %s: %w", event.ID, err) + } + totalRecipients += recipients + totalInserted += len(rows) + dispatchRows = append(dispatchRows, rows...) + } + if err := w.releases.MarkProcessed(ctx, tx, eventIDs(fanout), nil); err != nil { + return 0, err + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit fanout tx: %w", err) + } + + // Publish realtime events only after the delivery transaction commits. + // Failures here are tolerable: the durable rows appear on reconnect. + for _, row := range dispatchRows { + if err := w.dispatcher.Dispatch(ctx, row); err != nil { + w.logger.Warn("notification dispatch failed", + "delivery_id", row.ID, "profile_id", row.ProfileID, "error", err) + } + } + + w.logger.Info("fanout batch processed", + "claimed", len(events), + "fanned_out", len(fanout), + "suppressed", len(suppressed), + "stale", len(stale), + "recipient_count", totalRecipients, + "inserted_count", totalInserted, + "deduped_count", totalRecipients-totalInserted, + "duration_ms", time.Since(started).Milliseconds(), + ) + return len(events), nil +} + +// fanOutEvent resolves recipients for one release event and inserts +// deliveries. Returns dispatch payloads for the rows actually inserted and +// the candidate recipient count. +func (w *FanoutWorker) fanOutEvent(ctx context.Context, tx pgx.Tx, event ReleaseEvent) ([]DeliveryRow, int, error) { + candidates, err := w.interests.ListActiveBySeries(ctx, tx, event.LibraryID, event.SeriesID) + if err != nil { + return nil, 0, err + } + if len(candidates) == 0 { + return nil, 0, nil + } + + profileIDs := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + profileIDs = append(profileIDs, candidate.ProfileID) + } + prefs, err := w.preferences.GetMany(ctx, tx, profileIDs) + if err != nil { + return nil, 0, err + } + + pending := make(map[string]pendingDelivery, len(candidates)) + toInsert := make([]Delivery, 0, len(candidates)) + for _, candidate := range candidates { + flags, eligible := EvaluateRecipient(candidate, prefs[candidate.ProfileID], event.EpisodeKey) + if !eligible { + continue + } + flagsJSON, err := json.Marshal(flags) + if err != nil { + return nil, 0, fmt.Errorf("marshal reason flags: %w", err) + } + eventID := event.ID + libraryID := event.LibraryID + seriesID := event.SeriesID + episodeID := event.EpisodeID + delivery := Delivery{ + ID: ulid.Make().String(), + ReleaseEventID: &eventID, + UserID: candidate.UserID, + ProfileID: candidate.ProfileID, + LibraryID: &libraryID, + SeriesID: &seriesID, + EpisodeID: &episodeID, + Type: DeliveryTypeEpisodeAvailable, + ReasonFlags: flagsJSON, + } + pending[candidate.ProfileID] = pendingDelivery{delivery: delivery, flags: flags} + toInsert = append(toInsert, delivery) + } + if len(toInsert) == 0 { + return nil, len(candidates), nil + } + + inserted, err := w.deliveries.BulkInsert(ctx, tx, toInsert) + if err != nil { + return nil, 0, err + } + // Durable dispatch outbox: enqueue per-target attempt rows in the same + // transaction so a crash after commit delays webhooks instead of silently + // dropping them. Push channels (specs 02-03) plug in here the same way. + if err := w.enqueueWebhookOutbox(ctx, tx, inserted, pending); err != nil { + return nil, 0, err + } + if err := w.enqueueWebPushOutbox(ctx, tx, inserted); err != nil { + return nil, 0, err + } + + notifiedProfiles := make([]string, 0, len(inserted)) + for _, row := range inserted { + notifiedProfiles = append(notifiedProfiles, row.ProfileID) + } + if err := w.interests.GuardedSetLastNotified(ctx, tx, event.LibraryID, event.SeriesID, event.EpisodeKey, notifiedProfiles); err != nil { + return nil, 0, err + } + + display, err := loadEventDisplay(ctx, tx, event.EpisodeID) + if err != nil { + return nil, 0, err + } + dispatchRows := make([]DeliveryRow, 0, len(inserted)) + for _, row := range inserted { + entry, ok := pending[row.ProfileID] + if !ok { + continue + } + delivery := entry.delivery + delivery.ID = row.ID + delivery.CreatedAt = row.CreatedAt + seasonNumber := event.SeasonNumber + episodeNumber := event.EpisodeNumber + dispatchRows = append(dispatchRows, DeliveryRow{ + Delivery: delivery, + SeriesTitle: display.seriesTitle, + EpisodeTitle: display.episodeTitle, + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + PosterPath: display.posterPath, + PosterThumbhash: display.posterThumbhash, + }) + } + return dispatchRows, len(candidates), nil +} + +// pendingDelivery pairs a candidate delivery with its matched reason flags so +// the outbox enqueue can apply per-webhook reason filters. +type pendingDelivery struct { + delivery Delivery + flags ReasonFlags +} + +// enqueueWebPushOutbox inserts `pending` web push attempt rows for each newly +// inserted delivery, inside the fanout transaction. Subscriptions have no +// per-reason filters: profile preferences already gated delivery creation, +// and push volume is bounded by the per-series burst cap. +func (w *FanoutWorker) enqueueWebPushOutbox(ctx context.Context, tx pgx.Tx, inserted []InsertedDelivery) error { + if w.webPush == nil || len(inserted) == 0 || !w.settings.WebPushEnabled(ctx) { + return nil + } + profileSet := make(map[string]struct{}, len(inserted)) + profileIDs := make([]string, 0, len(inserted)) + for _, row := range inserted { + if _, ok := profileSet[row.ProfileID]; !ok { + profileSet[row.ProfileID] = struct{}{} + profileIDs = append(profileIDs, row.ProfileID) + } + } + subsByProfile, err := w.webPush.ListEnabledByProfiles(ctx, tx, profileIDs) + if err != nil { + return err + } + attempts := make([]DeliveryAttempt, 0, len(inserted)) + for _, row := range inserted { + for _, sub := range subsByProfile[row.ProfileID] { + attempts = append(attempts, DeliveryAttempt{ + ID: ulid.Make().String(), + NotificationDeliveryID: row.ID, + TargetID: sub.ID, + }) + } + } + return w.webPush.EnqueueAttempts(ctx, tx, attempts) +} + +// enqueueWebhookOutbox inserts `pending` webhook attempt rows for each newly +// inserted delivery, inside the fanout transaction. Two filters apply: +// per-webhook reason flags, and the per-profile delivery rate limit +// (over-limit notifications stay in the inbox; webhooks just don't fire). +func (w *FanoutWorker) enqueueWebhookOutbox(ctx context.Context, tx pgx.Tx, inserted []InsertedDelivery, pending map[string]pendingDelivery) error { + if w.webhooks == nil || len(inserted) == 0 || !w.settings.WebhooksEnabled(ctx) { + return nil + } + profileSet := make(map[string]struct{}, len(inserted)) + profileIDs := make([]string, 0, len(inserted)) + for _, row := range inserted { + if _, ok := profileSet[row.ProfileID]; !ok { + profileSet[row.ProfileID] = struct{}{} + profileIDs = append(profileIDs, row.ProfileID) + } + } + hooksByProfile, err := w.webhooks.ListEnabledByProfiles(ctx, tx, profileIDs) + if err != nil { + return err + } + rateLimit := w.settings.WebhooksDeliveriesPerMinute(ctx) + + attempts := make([]DeliveryAttempt, 0, len(inserted)) + rateLimited := 0 + for _, row := range inserted { + entry, ok := pending[row.ProfileID] + if !ok { + continue + } + for _, hook := range hooksByProfile[row.ProfileID] { + if !hook.MatchesReasons(entry.flags) { + continue + } + if w.rateLimiter != nil && !w.rateLimiter.Allow(row.ProfileID, rateLimit) { + rateLimited++ + continue + } + attempts = append(attempts, DeliveryAttempt{ + ID: ulid.Make().String(), + NotificationDeliveryID: row.ID, + TargetID: hook.ID, + }) + } + } + if rateLimited > 0 { + w.logger.Warn("webhook deliveries rate limited", "skipped", rateLimited) + } + return w.webhooks.EnqueueAttempts(ctx, tx, attempts) +} + +type eventDisplay struct { + seriesTitle string + episodeTitle string + posterPath string + posterThumbhash string +} + +// loadEventDisplay fetches per-event display metadata once and reuses it for +// every recipient's dispatch payload. +func loadEventDisplay(ctx context.Context, tx pgx.Tx, episodeID string) (eventDisplay, error) { + var display eventDisplay + err := tx.QueryRow(ctx, ` + SELECT COALESCE(e.title, ''), COALESCE(s.title, ''), + COALESCE(s.poster_path, ''), COALESCE(s.poster_thumbhash, '') + FROM episodes e + LEFT JOIN media_items s ON s.content_id = e.series_id + WHERE e.content_id = $1`, + episodeID, + ).Scan(&display.episodeTitle, &display.seriesTitle, &display.posterPath, &display.posterThumbhash) + if errors.Is(err, pgx.ErrNoRows) { + return eventDisplay{}, nil + } + if err != nil { + return display, fmt.Errorf("load event display metadata: %w", err) + } + return display, nil +} + +func eventIDs(events []ReleaseEvent) []string { + ids := make([]string, 0, len(events)) + for _, event := range events { + ids = append(ids, event.ID) + } + return ids +} diff --git a/internal/notifications/interest_hooks.go b/internal/notifications/interest_hooks.go new file mode 100644 index 00000000..4a793c51 --- /dev/null +++ b/internal/notifications/interest_hooks.go @@ -0,0 +1,236 @@ +package notifications + +import ( + "context" + "log/slog" + "time" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// WrapUserStoreProvider decorates the shared user-store provider so every +// favorites, watchlist, and watch-progress mutation — regardless of which +// path performed it (REST handlers, jellycompat, history imports, playback +// stop, watch sync) — queues an interest recompute. Hooking the lowest shared +// layer keeps the seven-plus mutation call sites hook-free and drift-free. +// +// Progress writes queue only on state *transitions* (a row appearing, the +// in-progress flag flipping, completion crossing, rows being cleared): +// progress sync ticks fire continuously during playback on a busy server, and +// recomputing interest on every tick would be a pointless hot write path. +func WrapUserStoreProvider(inner userstore.UserStoreProvider, system *System) userstore.UserStoreProvider { + if inner == nil || system == nil { + return inner + } + return &interestTrackingProvider{inner: inner, system: system} +} + +type interestTrackingProvider struct { + inner userstore.UserStoreProvider + system *System +} + +func (p *interestTrackingProvider) ForUser(ctx context.Context, userID int) (userstore.UserStore, error) { + store, err := p.inner.ForUser(ctx, userID) + if err != nil || store == nil { + return store, err + } + tracked := &interestTrackingStore{UserStore: store, userID: userID, system: p.system, updater: p.system.Interest} + // Preserve the DeviceRegistry interface upgrade some callers probe for. + if registry, ok := store.(userstore.DeviceRegistry); ok { + return &interestTrackingStoreWithDevices{ + interestTrackingStore: tracked, + DeviceRegistry: registry, + }, nil + } + return tracked, nil +} + +func (p *interestTrackingProvider) Close() error { + return p.inner.Close() +} + +type interestTrackingStore struct { + userstore.UserStore + userID int + system *System + updater *InterestUpdater +} + +type interestTrackingStoreWithDevices struct { + *interestTrackingStore + userstore.DeviceRegistry +} + +// progressState is the transition-relevant projection of a progress row. +type progressState struct { + exists bool + inProgress bool + completed bool +} + +func (s *interestTrackingStore) currentProgressState(ctx context.Context, profileID, mediaItemID string) progressState { + entry, err := s.GetProgress(ctx, profileID, mediaItemID) + if err != nil || entry == nil { + return progressState{} + } + return progressState{ + exists: true, + inProgress: !entry.Completed && entry.PositionSeconds > 0, + completed: entry.Completed, + } +} + +func progressStateFromValues(position, duration float64, thresholds userstore.ProgressThresholds) progressState { + completed := duration > 0 && position/duration > userstore.WatchedFraction(thresholds.WatchedPct) + return progressState{ + exists: true, + inProgress: !completed && position > 0, + completed: completed, + } +} + +func (s *interestTrackingStore) queueOnTransition(profileID, mediaItemID string, before, after progressState) { + if before != after { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } +} + +// --- Favorites & watchlist: every mutation queues (user-action frequency). + +func (s *interestTrackingStore) AddFavorite(ctx context.Context, profileID, mediaItemID string) error { + err := s.UserStore.AddFavorite(ctx, profileID, mediaItemID) + if err == nil { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + return err +} + +func (s *interestTrackingStore) AddFavoriteAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) error { + err := s.UserStore.AddFavoriteAt(ctx, profileID, mediaItemID, addedAt) + if err == nil { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + return err +} + +func (s *interestTrackingStore) RemoveFavorite(ctx context.Context, profileID, mediaItemID string) error { + err := s.UserStore.RemoveFavorite(ctx, profileID, mediaItemID) + if err == nil { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + return err +} + +func (s *interestTrackingStore) AddToWatchlist(ctx context.Context, profileID, mediaItemID string) error { + err := s.UserStore.AddToWatchlist(ctx, profileID, mediaItemID) + if err == nil { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + return err +} + +func (s *interestTrackingStore) RemoveFromWatchlist(ctx context.Context, profileID, mediaItemID string) error { + err := s.UserStore.RemoveFromWatchlist(ctx, profileID, mediaItemID) + if err == nil { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + return err +} + +// --- Progress: queue on transitions only. + +func (s *interestTrackingStore) UpdateProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds userstore.ProgressThresholds) error { + before := s.currentProgressState(ctx, profileID, mediaItemID) + err := s.UserStore.UpdateProgress(ctx, profileID, mediaItemID, position, duration, thresholds) + if err == nil { + s.queueOnTransition(profileID, mediaItemID, before, progressStateFromValues(position, duration, thresholds)) + } + return err +} + +func (s *interestTrackingStore) SetProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds userstore.ProgressThresholds) error { + before := s.currentProgressState(ctx, profileID, mediaItemID) + err := s.UserStore.SetProgress(ctx, profileID, mediaItemID, position, duration, thresholds) + if err == nil { + s.queueOnTransition(profileID, mediaItemID, before, progressStateFromValues(position, duration, thresholds)) + } + return err +} + +func (s *interestTrackingStore) SetProgressAt(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) error { + before := s.currentProgressState(ctx, profileID, mediaItemID) + err := s.UserStore.SetProgressAt(ctx, profileID, mediaItemID, position, duration, completed, updatedAt) + if err == nil { + after := progressState{exists: true, inProgress: !completed && position > 0, completed: completed} + s.queueOnTransition(profileID, mediaItemID, before, after) + } + return err +} + +func (s *interestTrackingStore) SetProgressIfNewer(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) (bool, error) { + before := s.currentProgressState(ctx, profileID, mediaItemID) + applied, err := s.UserStore.SetProgressIfNewer(ctx, profileID, mediaItemID, position, duration, completed, updatedAt) + if err == nil && applied { + after := progressState{exists: true, inProgress: !completed && position > 0, completed: completed} + s.queueOnTransition(profileID, mediaItemID, before, after) + } + return applied, err +} + +func (s *interestTrackingStore) MarkWatched(ctx context.Context, profileID, mediaItemID string, duration float64) error { + before := s.currentProgressState(ctx, profileID, mediaItemID) + err := s.UserStore.MarkWatched(ctx, profileID, mediaItemID, duration) + if err == nil { + s.queueOnTransition(profileID, mediaItemID, before, progressState{exists: true, completed: true}) + } + return err +} + +func (s *interestTrackingStore) MarkProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error { + beforeStates, _ := s.ListProgressByMediaItems(ctx, profileID, mediaItemIDs) + err := s.UserStore.MarkProgressBatch(ctx, profileID, mediaItemIDs, updatedAt) + if err == nil { + for _, mediaItemID := range mediaItemIDs { + if entry, ok := beforeStates[mediaItemID]; ok && entry.Completed { + continue // already completed: no transition + } + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + } + return err +} + +func (s *interestTrackingStore) ClearProgress(ctx context.Context, profileID, mediaItemID string) error { + err := s.UserStore.ClearProgress(ctx, profileID, mediaItemID) + if err == nil { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + return err +} + +func (s *interestTrackingStore) ClearProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error { + err := s.UserStore.ClearProgressBatch(ctx, profileID, mediaItemIDs, updatedAt) + if err == nil { + for _, mediaItemID := range mediaItemIDs { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + } + return err +} + +// DeleteProfile purges notification state alongside the profile itself; +// profiles may live outside Postgres, so no cascade covers these tables. +// The purge is best-effort: a failure is logged, never surfaced as a +// profile-deletion failure (the retention task prunes leftovers). +func (s *interestTrackingStore) DeleteProfile(ctx context.Context, id string) error { + err := s.UserStore.DeleteProfile(ctx, id) + if err == nil { + purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if purgeErr := s.system.PurgeProfile(purgeCtx, id); purgeErr != nil { + slog.Warn("notifications: profile purge failed", "profile_id", id, "error", purgeErr) + } + } + return err +} diff --git a/internal/notifications/interest_repo.go b/internal/notifications/interest_repo.go new file mode 100644 index 00000000..256b87f2 --- /dev/null +++ b/internal/notifications/interest_repo.go @@ -0,0 +1,164 @@ +package notifications + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// InterestRepository owns profile_series_interest. +type InterestRepository struct { + pool *pgxpool.Pool +} + +// NewInterestRepository creates an InterestRepository. +func NewInterestRepository(pool *pgxpool.Pool) *InterestRepository { + return &InterestRepository{pool: pool} +} + +// ListActiveBySeries loads candidate recipients for one (library, series). +// This is the hot fanout query; it uses the partial active-interest index. +func (r *InterestRepository) ListActiveBySeries(ctx context.Context, tx pgx.Tx, libraryID int, seriesID string) ([]SeriesInterest, error) { + rows, err := tx.Query(ctx, ` + SELECT user_id, profile_id, library_id, series_id, + favorite, watchlist, continue_watching, next_up_candidate, + last_completed_episode_key, next_expected_episode_key, last_notified_episode_key, + updated_at + FROM profile_series_interest + WHERE library_id = $1 AND series_id = $2 + AND (favorite OR watchlist OR continue_watching OR next_up_candidate)`, + libraryID, seriesID) + if err != nil { + return nil, fmt.Errorf("list series interest: %w", err) + } + defer rows.Close() + + interests := make([]SeriesInterest, 0, 16) + for rows.Next() { + var interest SeriesInterest + if err := rows.Scan( + &interest.UserID, &interest.ProfileID, &interest.LibraryID, &interest.SeriesID, + &interest.Favorite, &interest.Watchlist, &interest.ContinueWatching, &interest.NextUpCandidate, + &interest.LastCompletedEpisodeKey, &interest.NextExpectedEpisodeKey, &interest.LastNotifiedEpisodeKey, + &interest.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan series interest: %w", err) + } + interests = append(interests, interest) + } + return interests, rows.Err() +} + +// UpsertRows writes recomputed interest rows. last_notified_episode_key is +// deliberately not touched: it is owned by the fanout worker. The DO UPDATE +// WHERE clause skips no-op writes so hot recompute paths do not churn rows. +func (r *InterestRepository) UpsertRows(ctx context.Context, interests []SeriesInterest) error { + if len(interests) == 0 { + return nil + } + var sb strings.Builder + sb.WriteString(` + INSERT INTO profile_series_interest + (user_id, profile_id, library_id, series_id, + favorite, watchlist, continue_watching, next_up_candidate, + last_completed_episode_key, next_expected_episode_key, updated_at) + VALUES `) + args := make([]any, 0, len(interests)*10) + for i, interest := range interests { + if i > 0 { + sb.WriteString(", ") + } + base := len(args) + sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,now())", + base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10)) + args = append(args, + interest.UserID, interest.ProfileID, interest.LibraryID, interest.SeriesID, + interest.Favorite, interest.Watchlist, interest.ContinueWatching, interest.NextUpCandidate, + interest.LastCompletedEpisodeKey, interest.NextExpectedEpisodeKey, + ) + } + sb.WriteString(` + ON CONFLICT (profile_id, library_id, series_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + favorite = EXCLUDED.favorite, + watchlist = EXCLUDED.watchlist, + continue_watching = EXCLUDED.continue_watching, + next_up_candidate = EXCLUDED.next_up_candidate, + last_completed_episode_key = EXCLUDED.last_completed_episode_key, + next_expected_episode_key = EXCLUDED.next_expected_episode_key, + updated_at = now() + WHERE (profile_series_interest.favorite, + profile_series_interest.watchlist, + profile_series_interest.continue_watching, + profile_series_interest.next_up_candidate, + profile_series_interest.last_completed_episode_key, + profile_series_interest.next_expected_episode_key) + IS DISTINCT FROM + (EXCLUDED.favorite, EXCLUDED.watchlist, EXCLUDED.continue_watching, + EXCLUDED.next_up_candidate, EXCLUDED.last_completed_episode_key, + EXCLUDED.next_expected_episode_key)`) + if _, err := r.pool.Exec(ctx, sb.String(), args...); err != nil { + return fmt.Errorf("upsert series interest: %w", err) + } + return nil +} + +// DeleteStaleForProfileSeries removes interest rows for libraries the profile +// can no longer see (or the series no longer belongs to). keepLibraryIDs is +// the freshly computed target set; an empty set deletes all rows for the +// (profile, series) pair. +func (r *InterestRepository) DeleteStaleForProfileSeries(ctx context.Context, profileID, seriesID string, keepLibraryIDs []int) error { + _, err := r.pool.Exec(ctx, ` + DELETE FROM profile_series_interest + WHERE profile_id = $1 AND series_id = $2 AND NOT (library_id = ANY($3))`, + profileID, seriesID, keepLibraryIDs) + if err != nil { + return fmt.Errorf("delete stale series interest: %w", err) + } + return nil +} + +// GuardedSetLastNotified raises last_notified_episode_key for the given +// profiles. The < guard makes concurrent workers handling adjacent release +// events safe: the higher key wins regardless of commit order. +func (r *InterestRepository) GuardedSetLastNotified(ctx context.Context, tx pgx.Tx, libraryID int, seriesID string, episodeKey int, profileIDs []string) error { + if len(profileIDs) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + UPDATE profile_series_interest + SET last_notified_episode_key = $1 + WHERE library_id = $2 AND series_id = $3 AND profile_id = ANY($4) + AND (last_notified_episode_key IS NULL OR last_notified_episode_key < $1)`, + episodeKey, libraryID, seriesID, profileIDs) + if err != nil { + return fmt.Errorf("update last notified episode key: %w", err) + } + return nil +} + +// DeleteAllForProfile removes every interest row for a deleted profile. +// Profiles may live in per-user SQLite stores, so this cleanup cannot rely on +// a Postgres cascade. +func (r *InterestRepository) DeleteAllForProfile(ctx context.Context, profileID string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM profile_series_interest WHERE profile_id = $1`, profileID) + return err +} + +// PruneInert removes rows with no interest flags and no progression cursors; +// they can never produce a notification. +func (r *InterestRepository) PruneInert(ctx context.Context) (int64, error) { + tag, err := r.pool.Exec(ctx, ` + DELETE FROM profile_series_interest + WHERE NOT (favorite OR watchlist OR continue_watching OR next_up_candidate) + AND last_completed_episode_key IS NULL + AND next_expected_episode_key IS NULL`) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/internal/notifications/interest_updater.go b/internal/notifications/interest_updater.go new file mode 100644 index 00000000..f2c9ba76 --- /dev/null +++ b/internal/notifications/interest_updater.go @@ -0,0 +1,407 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ScopeResolver computes the effective library visibility for a +// (user, profile) outside of a request context. Satisfied by +// *access.Resolver. +type ScopeResolver interface { + Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error) +} + +const ( + interestFlushInterval = 2 * time.Second + interestQueryChunk = 500 + interestRecomputeTime = 30 * time.Second +) + +type interestMutation struct { + userID int + profileID string + itemID string +} + +// InterestUpdater maintains profile_series_interest from user-state +// mutations. Mutations are queued (cheap, non-blocking) and coalesced by a +// background loop: bursts like history imports or progress-sync ticks +// collapse into one recompute per (profile, series) per flush window. +// +// Recompute reads source-of-truth state through the userstore interface — +// user data may live in per-user SQLite stores, so no SQL joins against +// catalog tables are possible. +type InterestUpdater struct { + pool *pgxpool.Pool + interests *InterestRepository + stores userstore.UserStoreProvider + scopes ScopeResolver + logger *slog.Logger + + mu sync.Mutex + pending map[interestMutation]struct{} +} + +// NewInterestUpdater creates an InterestUpdater. +func NewInterestUpdater( + pool *pgxpool.Pool, + interests *InterestRepository, + stores userstore.UserStoreProvider, + scopes ScopeResolver, +) *InterestUpdater { + return &InterestUpdater{ + pool: pool, + interests: interests, + stores: stores, + scopes: scopes, + logger: slog.Default().With("component", "notifications.interest"), + pending: make(map[interestMutation]struct{}), + } +} + +// QueueItemMutation records that a profile's relationship to a media item +// (favorite, watchlist, watch progress) changed. The item is resolved to its +// parent series asynchronously; movie targets are ignored. Safe to call from +// hot request paths. +func (u *InterestUpdater) QueueItemMutation(userID int, profileID, itemID string) { + if u == nil || userID <= 0 || profileID == "" || itemID == "" { + return + } + u.mu.Lock() + u.pending[interestMutation{userID: userID, profileID: profileID, itemID: itemID}] = struct{}{} + u.mu.Unlock() +} + +// Run drains the mutation queue until ctx is canceled. +func (u *InterestUpdater) Run(ctx context.Context) { + ticker := time.NewTicker(interestFlushInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + u.flush(ctx) + } + } +} + +func (u *InterestUpdater) flush(ctx context.Context) { + u.mu.Lock() + if len(u.pending) == 0 { + u.mu.Unlock() + return + } + batch := u.pending + u.pending = make(map[interestMutation]struct{}) + u.mu.Unlock() + + // Resolve items to series and dedupe to one recompute per + // (user, profile, series). + type recomputeKey struct { + userID int + profileID string + seriesID string + } + seen := make(map[recomputeKey]struct{}, len(batch)) + for mutation := range batch { + if ctx.Err() != nil { + return + } + seriesID, ok, err := u.resolveSeriesID(ctx, mutation.itemID) + if err != nil { + u.logger.Warn("interest series resolution failed", + "item_id", mutation.itemID, "error", err) + continue + } + if !ok { + continue // movies and unknown items have no series interest + } + key := recomputeKey{mutation.userID, mutation.profileID, seriesID} + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + + recomputeCtx, cancel := context.WithTimeout(ctx, interestRecomputeTime) + err = u.RecomputeSeries(recomputeCtx, mutation.userID, mutation.profileID, seriesID) + cancel() + if err != nil { + u.logger.Warn("interest recompute failed", + "user_id", mutation.userID, "profile_id", mutation.profileID, + "series_id", seriesID, "error", err) + } + } +} + +// resolveSeriesID maps a media item ID (series, season, or episode) to its +// series content ID. Returns ok=false for movies and unknown items. +func (u *InterestUpdater) resolveSeriesID(ctx context.Context, itemID string) (string, bool, error) { + var seriesID string + err := u.pool.QueryRow(ctx, ` + SELECT series_id FROM episodes WHERE content_id = $1 + UNION ALL + SELECT series_id FROM seasons WHERE content_id = $1 + UNION ALL + SELECT content_id FROM media_items WHERE content_id = $1 AND type = 'series' + LIMIT 1`, + itemID, + ).Scan(&seriesID) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return seriesID, seriesID != "", nil +} + +// RecomputeSeries rebuilds the (profile, series) interest rows from +// source-of-truth user state. It is the single shared path for live updates, +// backfill, and repair, so all three stay drift-free. +func (u *InterestUpdater) RecomputeSeries(ctx context.Context, userID int, profileID, seriesID string) error { + store, err := u.stores.ForUser(ctx, userID) + if err != nil { + return fmt.Errorf("open user store: %w", err) + } + + episodeKeys, seasonIDs, err := u.loadSeriesStructure(ctx, seriesID) + if err != nil { + return err + } + episodeIDs := make([]string, 0, len(episodeKeys)) + for id := range episodeKeys { + episodeIDs = append(episodeIDs, id) + } + + // Favorites/watchlist against episode or season items resolve to the + // series, so the membership check spans the series and its children. + interestTargets := append([]string{seriesID}, seasonIDs...) + interestTargets = append(interestTargets, episodeIDs...) + + favorite, err := anyInBatches(interestTargets, func(chunk []string) (bool, error) { + matches, err := store.ListFavoritesByMediaItems(ctx, profileID, chunk) + return anyTrue(matches), err + }) + if err != nil { + return fmt.Errorf("load favorites: %w", err) + } + watchlist, err := anyInBatches(interestTargets, func(chunk []string) (bool, error) { + matches, err := store.ListWatchlistByMediaItems(ctx, profileID, chunk) + return anyTrue(matches), err + }) + if err != nil { + return fmt.Errorf("load watchlist: %w", err) + } + + continueWatching := false + hasProgression := false + lastCompletedKey := 0 + hasCompleted := false + for start := 0; start < len(episodeIDs); start += interestQueryChunk { + end := min(start+interestQueryChunk, len(episodeIDs)) + progress, err := store.ListProgressByMediaItems(ctx, profileID, episodeIDs[start:end]) + if err != nil { + return fmt.Errorf("load progress: %w", err) + } + for episodeID, entry := range progress { + hasProgression = true + if !entry.Completed && entry.PositionSeconds > 0 { + continueWatching = true + } + if entry.Completed { + if key, ok := episodeKeys[episodeID]; ok && (!hasCompleted || key > lastCompletedKey) { + lastCompletedKey = key + hasCompleted = true + } + } + } + } + + // Conservative progression cursor: next_expected = last completed key + 1. + // When the profile has gaps (completed E10 with E05 unwatched), a late- + // arriving E05 will not match next_up (it may still match favorite / + // watchlist / continue_watching). The plan explicitly prefers this + // under-notify tradeoff over per-episode gap scans at recompute time. + var lastCompleted, nextExpected *int + if hasCompleted { + completed := lastCompletedKey + expected := lastCompletedKey + 1 + lastCompleted = &completed + nextExpected = &expected + } + nextUpCandidate := hasProgression + + flags := SeriesInterest{ + UserID: userID, + ProfileID: profileID, + SeriesID: seriesID, + Favorite: favorite, + Watchlist: watchlist, + ContinueWatching: continueWatching, + NextUpCandidate: nextUpCandidate, + LastCompletedEpisodeKey: lastCompleted, + NextExpectedEpisodeKey: nextExpected, + } + + if !flags.HasAnyInterest() && lastCompleted == nil { + // Nothing can ever notify: drop all rows for the pair. + return u.interests.DeleteStaleForProfileSeries(ctx, profileID, seriesID, []int{}) + } + + libraryIDs, err := u.visibleSeriesLibraries(ctx, userID, profileID, seriesID) + if err != nil { + return err + } + if len(libraryIDs) == 0 { + return u.interests.DeleteStaleForProfileSeries(ctx, profileID, seriesID, []int{}) + } + + rows := make([]SeriesInterest, 0, len(libraryIDs)) + for _, libraryID := range libraryIDs { + row := flags + row.LibraryID = libraryID + rows = append(rows, row) + } + if err := u.interests.UpsertRows(ctx, rows); err != nil { + return err + } + return u.interests.DeleteStaleForProfileSeries(ctx, profileID, seriesID, libraryIDs) +} + +// loadSeriesStructure returns the series' episode keys (content_id -> +// episode_key) and season content IDs. +func (u *InterestUpdater) loadSeriesStructure(ctx context.Context, seriesID string) (map[string]int, []string, error) { + rows, err := u.pool.Query(ctx, + `SELECT content_id, season_number, episode_number FROM episodes WHERE series_id = $1`, + seriesID) + if err != nil { + return nil, nil, fmt.Errorf("load series episodes: %w", err) + } + episodeKeys := make(map[string]int, 64) + for rows.Next() { + var contentID string + var seasonNumber, episodeNumber int + if err := rows.Scan(&contentID, &seasonNumber, &episodeNumber); err != nil { + rows.Close() + return nil, nil, fmt.Errorf("scan series episode: %w", err) + } + if !ValidEpisodeOrdinals(seasonNumber, episodeNumber) { + continue + } + episodeKeys[contentID] = EpisodeKey(seasonNumber, episodeNumber) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, err + } + + seasonRows, err := u.pool.Query(ctx, + `SELECT content_id FROM seasons WHERE series_id = $1`, seriesID) + if err != nil { + return nil, nil, fmt.Errorf("load series seasons: %w", err) + } + seasonIDs := make([]string, 0, 8) + for seasonRows.Next() { + var contentID string + if err := seasonRows.Scan(&contentID); err != nil { + seasonRows.Close() + return nil, nil, fmt.Errorf("scan series season: %w", err) + } + seasonIDs = append(seasonIDs, contentID) + } + seasonRows.Close() + return episodeKeys, seasonIDs, seasonRows.Err() +} + +// visibleSeriesLibraries intersects the series' library memberships with the +// profile's effective library visibility. The interest index must never +// assume global library visibility. +func (u *InterestUpdater) visibleSeriesLibraries(ctx context.Context, userID int, profileID, seriesID string) ([]int, error) { + rows, err := u.pool.Query(ctx, + `SELECT media_folder_id FROM media_item_libraries WHERE content_id = $1`, seriesID) + if err != nil { + return nil, fmt.Errorf("load series library memberships: %w", err) + } + memberships := make([]int, 0, 4) + for rows.Next() { + var libraryID int + if err := rows.Scan(&libraryID); err != nil { + rows.Close() + return nil, fmt.Errorf("scan series library membership: %w", err) + } + memberships = append(memberships, libraryID) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if len(memberships) == 0 { + return nil, nil + } + + scope, err := u.scopes.Resolve(ctx, access.ResolveInput{ + UserID: userID, + ProfileID: profileID, + SkipPINVerification: true, + }) + if err != nil { + return nil, fmt.Errorf("resolve profile scope: %w", err) + } + + allowed := make(map[int]bool) + if scope.AllowedLibraryIDs != nil { + for _, id := range scope.AllowedLibraryIDs { + allowed[id] = true + } + } + disabled := make(map[int]bool, len(scope.DisabledLibraryIDs)) + for _, id := range scope.DisabledLibraryIDs { + disabled[id] = true + } + + visible := make([]int, 0, len(memberships)) + for _, libraryID := range memberships { + if scope.AllowedLibraryIDs != nil && !allowed[libraryID] { + continue + } + if disabled[libraryID] { + continue + } + visible = append(visible, libraryID) + } + return visible, nil +} + +func anyInBatches(ids []string, check func(chunk []string) (bool, error)) (bool, error) { + for start := 0; start < len(ids); start += interestQueryChunk { + end := min(start+interestQueryChunk, len(ids)) + found, err := check(ids[start:end]) + if err != nil { + return false, err + } + if found { + return true, nil + } + } + return false, nil +} + +func anyTrue(values map[string]bool) bool { + for _, v := range values { + if v { + return true + } + } + return false +} diff --git a/internal/notifications/preferences_repo.go b/internal/notifications/preferences_repo.go new file mode 100644 index 00000000..2d1b1b39 --- /dev/null +++ b/internal/notifications/preferences_repo.go @@ -0,0 +1,98 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PreferencesRepository owns notification_preferences. +type PreferencesRepository struct { + pool *pgxpool.Pool +} + +// NewPreferencesRepository creates a PreferencesRepository. +func NewPreferencesRepository(pool *pgxpool.Pool) *PreferencesRepository { + return &PreferencesRepository{pool: pool} +} + +// Get returns the profile's preferences, defaulting missing rows to +// all-enabled. +func (r *PreferencesRepository) Get(ctx context.Context, profileID string) (Preferences, error) { + prefs := DefaultPreferences(profileID) + err := r.pool.QueryRow(ctx, ` + SELECT enabled, notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up, updated_at + FROM notification_preferences + WHERE profile_id = $1`, + profileID, + ).Scan(&prefs.Enabled, &prefs.NotifyFavorites, &prefs.NotifyWatchlist, + &prefs.NotifyContinueWatching, &prefs.NotifyNextUp, &prefs.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return prefs, nil + } + if err != nil { + return prefs, fmt.Errorf("get notification preferences: %w", err) + } + return prefs, nil +} + +// GetMany returns preferences for the given profiles, defaulting missing rows +// to all-enabled. Used by the fanout worker. +func (r *PreferencesRepository) GetMany(ctx context.Context, tx pgx.Tx, profileIDs []string) (map[string]Preferences, error) { + out := make(map[string]Preferences, len(profileIDs)) + for _, profileID := range profileIDs { + out[profileID] = DefaultPreferences(profileID) + } + if len(profileIDs) == 0 { + return out, nil + } + rows, err := tx.Query(ctx, ` + SELECT profile_id, enabled, notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up, updated_at + FROM notification_preferences + WHERE profile_id = ANY($1)`, + profileIDs) + if err != nil { + return nil, fmt.Errorf("get notification preferences batch: %w", err) + } + defer rows.Close() + for rows.Next() { + var prefs Preferences + if err := rows.Scan(&prefs.ProfileID, &prefs.Enabled, &prefs.NotifyFavorites, + &prefs.NotifyWatchlist, &prefs.NotifyContinueWatching, &prefs.NotifyNextUp, &prefs.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan notification preferences: %w", err) + } + out[prefs.ProfileID] = prefs + } + return out, rows.Err() +} + +// Upsert writes the profile's preferences. Idempotent. +func (r *PreferencesRepository) Upsert(ctx context.Context, prefs Preferences) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO notification_preferences + (profile_id, enabled, notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) + ON CONFLICT (profile_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + notify_favorites = EXCLUDED.notify_favorites, + notify_watchlist = EXCLUDED.notify_watchlist, + notify_continue_watching = EXCLUDED.notify_continue_watching, + notify_next_up = EXCLUDED.notify_next_up, + updated_at = now()`, + prefs.ProfileID, prefs.Enabled, prefs.NotifyFavorites, + prefs.NotifyWatchlist, prefs.NotifyContinueWatching, prefs.NotifyNextUp) + if err != nil { + return fmt.Errorf("upsert notification preferences: %w", err) + } + return nil +} + +// DeleteForProfile removes a deleted profile's preference row. +func (r *PreferencesRepository) DeleteForProfile(ctx context.Context, profileID string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM notification_preferences WHERE profile_id = $1`, profileID) + return err +} diff --git a/internal/notifications/release_repo.go b/internal/notifications/release_repo.go new file mode 100644 index 00000000..dd845df6 --- /dev/null +++ b/internal/notifications/release_repo.go @@ -0,0 +1,282 @@ +package notifications + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/oklog/ulid/v2" +) + +// ReleaseRepository owns episode_availability, notification_library_seed_state, +// and release_events. +type ReleaseRepository struct { + pool *pgxpool.Pool +} + +// NewReleaseRepository creates a ReleaseRepository. +func NewReleaseRepository(pool *pgxpool.Pool) *ReleaseRepository { + return &ReleaseRepository{pool: pool} +} + +// IsLibrarySeeded reports whether availability seeding completed for the +// library. Unseeded libraries record availability silently (no release +// events). +func (r *ReleaseRepository) IsLibrarySeeded(ctx context.Context, libraryID int) (bool, error) { + var seeded bool + err := r.pool.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM notification_library_seed_state WHERE library_id = $1)`, + libraryID, + ).Scan(&seeded) + return seeded, err +} + +// MarkLibrarySeeded records that availability seeding completed for the +// library. Idempotent. +func (r *ReleaseRepository) MarkLibrarySeeded(ctx context.Context, libraryID int) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO notification_library_seed_state (library_id, seeded_at) + VALUES ($1, now()) + ON CONFLICT (library_id) DO NOTHING`, libraryID) + return err +} + +// availabilityInsertColumns is shared by the library-wide and path-scoped +// availability inserts. +const availabilityReturning = ` RETURNING episode_id, series_id, season_number, episode_number, episode_key, available_at` + +// availabilityOrdinalGuard excludes episode rows whose ordinals cannot fold +// into an int4 episode_key; without the season upper bound the key expression +// overflows in Postgres and aborts the whole insert. Must stay in sync with +// ValidEpisodeOrdinals (episode_key.go). +var availabilityOrdinalGuard = fmt.Sprintf( + `e.season_number BETWEEN 0 AND %d AND e.episode_number BETWEEN 0 AND %d`, + episodeKeyMaxSeason, episodeKeySeasonMultiplier-1) + +// availabilityKeyExpr computes episode_key in SQL with the same fold as +// EpisodeKey (episode_key.go). +var availabilityKeyExpr = fmt.Sprintf( + `e.season_number * %d + e.episode_number`, episodeKeySeasonMultiplier) + +// RecordAvailabilityForLibrary inserts episode_availability rows for every +// episode currently present in the library (one-way, idempotent) and, when +// emitEvents is true, creates release events for the newly inserted rows. +// Returns (availability rows inserted, release events created). +func (r *ReleaseRepository) RecordAvailabilityForLibrary(ctx context.Context, libraryID int, emitEvents bool) (int, int, error) { + query := ` + INSERT INTO episode_availability + (library_id, episode_id, series_id, season_number, episode_number, episode_key) + SELECT el.media_folder_id, e.content_id, e.series_id, e.season_number, e.episode_number, + ` + availabilityKeyExpr + ` + FROM episode_libraries el + JOIN episodes e ON e.content_id = el.episode_id + WHERE el.media_folder_id = $1 + AND ` + availabilityOrdinalGuard + ` + ON CONFLICT (library_id, episode_id) DO NOTHING` + availabilityReturning + return r.recordAvailability(ctx, libraryID, emitEvents, query, []any{libraryID}) +} + +// RecordAvailabilityForPaths inserts availability rows for episodes whose +// playable files live under the given scope paths (subtree/file ingest), and +// optionally creates release events for newly inserted rows. +func (r *ReleaseRepository) RecordAvailabilityForPaths(ctx context.Context, libraryID int, scopePaths []string, emitEvents bool) (int, int, error) { + if len(scopePaths) == 0 { + return 0, 0, nil + } + args := []any{libraryID} + scopeConds := make([]string, 0, len(scopePaths)) + for _, path := range scopePaths { + args = append(args, path) + idx := len(args) + scopeConds = append(scopeConds, + fmt.Sprintf("(mf.file_path = $%d OR starts_with(mf.file_path, $%d || '/'))", idx, idx)) + } + query := ` + INSERT INTO episode_availability + (library_id, episode_id, series_id, season_number, episode_number, episode_key) + SELECT DISTINCT mf.media_folder_id, e.content_id, e.series_id, e.season_number, e.episode_number, + ` + availabilityKeyExpr + ` + FROM media_files mf + JOIN episodes e ON e.content_id = mf.episode_id + WHERE mf.media_folder_id = $1 + AND mf.missing_since IS NULL + AND mf.episode_id IS NOT NULL + AND ` + availabilityOrdinalGuard + ` + AND (` + strings.Join(scopeConds, " OR ") + `) + ON CONFLICT (library_id, episode_id) DO NOTHING` + availabilityReturning + return r.recordAvailability(ctx, libraryID, emitEvents, query, args) +} + +type newAvailability struct { + EpisodeID string + SeriesID string + SeasonNumber int + EpisodeNumber int + EpisodeKey int + AvailableAt time.Time +} + +// recordAvailability runs the availability insert and the optional release +// event insert in one short transaction, so an event is never created without +// its availability fact. +func (r *ReleaseRepository) recordAvailability(ctx context.Context, libraryID int, emitEvents bool, query string, args []any) (int, int, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return 0, 0, fmt.Errorf("begin availability tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + rows, err := tx.Query(ctx, query, args...) + if err != nil { + return 0, 0, fmt.Errorf("insert episode availability: %w", err) + } + inserted := make([]newAvailability, 0, 16) + for rows.Next() { + var row newAvailability + if err := rows.Scan(&row.EpisodeID, &row.SeriesID, &row.SeasonNumber, &row.EpisodeNumber, &row.EpisodeKey, &row.AvailableAt); err != nil { + rows.Close() + return 0, 0, fmt.Errorf("scan inserted availability: %w", err) + } + inserted = append(inserted, row) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, 0, fmt.Errorf("read inserted availability: %w", err) + } + + events := 0 + if emitEvents && len(inserted) > 0 { + events, err = insertReleaseEvents(ctx, tx, libraryID, inserted) + if err != nil { + return 0, 0, err + } + } + + if err := tx.Commit(ctx); err != nil { + return 0, 0, fmt.Errorf("commit availability tx: %w", err) + } + return len(inserted), events, nil +} + +func insertReleaseEvents(ctx context.Context, tx pgx.Tx, libraryID int, rows []newAvailability) (int, error) { + const chunkSize = 500 + total := 0 + for start := 0; start < len(rows); start += chunkSize { + end := min(start+chunkSize, len(rows)) + chunk := rows[start:end] + + var sb strings.Builder + sb.WriteString(` + INSERT INTO release_events + (id, library_id, series_id, episode_id, season_number, episode_number, episode_key, available_at, dedupe_key) + VALUES `) + args := make([]any, 0, len(chunk)*9) + for i, row := range chunk { + if i > 0 { + sb.WriteString(", ") + } + base := len(args) + sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)", + base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9)) + args = append(args, + ulid.Make().String(), + libraryID, + row.SeriesID, + row.EpisodeID, + row.SeasonNumber, + row.EpisodeNumber, + row.EpisodeKey, + row.AvailableAt, + fmt.Sprintf("%d:%s", libraryID, row.EpisodeID), + ) + } + sb.WriteString(" ON CONFLICT (dedupe_key) DO NOTHING") + tag, err := tx.Exec(ctx, sb.String(), args...) + if err != nil { + return total, fmt.Errorf("insert release events: %w", err) + } + total += int(tag.RowsAffected()) + } + return total, nil +} + +// ClaimUnprocessed locks and returns up to limit unprocessed release events +// older than the settle delay. Must run inside the caller's transaction; +// FOR UPDATE SKIP LOCKED keeps multiple nodes from double-processing. +func (r *ReleaseRepository) ClaimUnprocessed(ctx context.Context, tx pgx.Tx, settle time.Duration, limit int) ([]ReleaseEvent, error) { + rows, err := tx.Query(ctx, ` + SELECT id, library_id, series_id, episode_id, season_number, episode_number, + episode_key, available_at, dedupe_key, created_at + FROM release_events + WHERE processed_at IS NULL + AND created_at <= now() - ($1 * interval '1 second') + ORDER BY created_at + LIMIT $2 + FOR UPDATE SKIP LOCKED`, + settle.Seconds(), limit) + if err != nil { + return nil, fmt.Errorf("claim release events: %w", err) + } + defer rows.Close() + + events := make([]ReleaseEvent, 0, limit) + for rows.Next() { + var event ReleaseEvent + if err := rows.Scan( + &event.ID, &event.LibraryID, &event.SeriesID, &event.EpisodeID, + &event.SeasonNumber, &event.EpisodeNumber, &event.EpisodeKey, + &event.AvailableAt, &event.DedupeKey, &event.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan release event: %w", err) + } + events = append(events, event) + } + return events, rows.Err() +} + +// MarkProcessed marks events processed, optionally tagging them with a +// suppression reason. +func (r *ReleaseRepository) MarkProcessed(ctx context.Context, tx pgx.Tx, ids []string, suppressedReason *string) error { + if len(ids) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + UPDATE release_events + SET processed_at = now(), suppressed_reason = $2 + WHERE id = ANY($1)`, + ids, suppressedReason) + if err != nil { + return fmt.Errorf("mark release events processed: %w", err) + } + return nil +} + +// DeleteProcessedBefore prunes processed release events older than the cutoff +// (retention). Inbox rows survive via ON DELETE SET NULL. +func (r *ReleaseRepository) DeleteProcessedBefore(ctx context.Context, cutoff time.Time) (int64, error) { + tag, err := r.pool.Exec(ctx, ` + DELETE FROM release_events + WHERE processed_at IS NOT NULL AND created_at < $1`, cutoff) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// DeleteUnprocessedBefore prunes unprocessed release events older than the +// fanout staleness horizon. These accumulate without bound when fanout is +// disabled while availability detection keeps emitting events; the fanout +// worker suppresses them as stale rather than delivering them, so retention +// can reclaim them directly. +func (r *ReleaseRepository) DeleteUnprocessedBefore(ctx context.Context, cutoff time.Time) (int64, error) { + tag, err := r.pool.Exec(ctx, ` + DELETE FROM release_events + WHERE processed_at IS NULL AND created_at < $1`, cutoff) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/internal/notifications/release_types.go b/internal/notifications/release_types.go new file mode 100644 index 00000000..e323c51d --- /dev/null +++ b/internal/notifications/release_types.go @@ -0,0 +1,139 @@ +package notifications + +import ( + "time" +) + +// Domain types for the user-facing release-notification system +// (docs/superpowers/plans/notifications/). These are unrelated to the +// operational catalog/jobs Hub defined in hub.go. + +// DeliveryTypeEpisodeAvailable is the v1 primary delivery type. The type +// registry is extensible; clients must render unknown types with a generic +// fallback. +const DeliveryTypeEpisodeAvailable = "episode.available" + +// SuppressedReasonSeriesBurst marks release events consumed by the per-series +// burst cap without fanout. +const SuppressedReasonSeriesBurst = "series_burst" + +// SuppressedReasonStale marks release events that aged past the fanout +// staleness horizon before they could fan out (fanout disabled for a stretch, +// extended downtime); delivering them long after the fact would be noise. +const SuppressedReasonStale = "stale" + +// ReleaseEvent is one logical "episode became newly available in a library" +// event. dedupe_key is "{library_id}:{episode_id}". +type ReleaseEvent struct { + ID string + LibraryID int + SeriesID string + EpisodeID string + SeasonNumber int + EpisodeNumber int + EpisodeKey int + AvailableAt time.Time + DedupeKey string + ProcessedAt *time.Time + SuppressedReason *string + CreatedAt time.Time +} + +// SeriesInterest is the compact recipient-index row used by fanout. +type SeriesInterest struct { + UserID int + ProfileID string + LibraryID int + SeriesID string + Favorite bool + Watchlist bool + ContinueWatching bool + NextUpCandidate bool + LastCompletedEpisodeKey *int + NextExpectedEpisodeKey *int + LastNotifiedEpisodeKey *int + UpdatedAt time.Time +} + +// HasAnyInterest reports whether at least one interest flag is set. +func (i SeriesInterest) HasAnyInterest() bool { + return i.Favorite || i.Watchlist || i.ContinueWatching || i.NextUpCandidate +} + +// ReasonFlags records which interest reasons matched for an +// episode.available delivery. +type ReasonFlags struct { + Favorite bool `json:"favorite"` + Watchlist bool `json:"watchlist"` + ContinueWatching bool `json:"continue_watching"` + NextUp bool `json:"next_up"` +} + +// Any reports whether at least one reason matched. +func (f ReasonFlags) Any() bool { + return f.Favorite || f.Watchlist || f.ContinueWatching || f.NextUp +} + +// Preferences are the per-profile notification controls. Missing rows default +// to all-enabled. +type Preferences struct { + ProfileID string `json:"profile_id"` + Enabled bool `json:"enabled"` + NotifyFavorites bool `json:"notify_favorites"` + NotifyWatchlist bool `json:"notify_watchlist"` + NotifyContinueWatching bool `json:"notify_continue_watching"` + NotifyNextUp bool `json:"notify_next_up"` + UpdatedAt time.Time `json:"-"` +} + +// DefaultPreferences returns the all-enabled defaults for a profile. +func DefaultPreferences(profileID string) Preferences { + return Preferences{ + ProfileID: profileID, + Enabled: true, + NotifyFavorites: true, + NotifyWatchlist: true, + NotifyContinueWatching: true, + NotifyNextUp: true, + } +} + +// Delivery is a durable per-profile inbox row. +type Delivery struct { + ID string + ReleaseEventID *string + UserID int + ProfileID string + LibraryID *int + SeriesID *string + EpisodeID *string + Type string + ReasonFlags []byte // raw JSONB payload + Status string + ReadAt *time.Time + DeliveredAt *time.Time + CreatedAt time.Time +} + +// DeliveryRow is a delivery enriched with the display metadata clients need +// to render a row without an extra lookup. It is the shared shape for the +// inbox API, the websocket snapshot, and realtime dispatch payloads. +type DeliveryRow struct { + Delivery + SeriesTitle string + EpisodeTitle string + SeasonNumber *int + EpisodeNumber *int + PosterPath string + PosterThumbhash string +} + +// InsertedDelivery identifies a delivery row actually inserted by a bulk +// insert (as opposed to deduped by ON CONFLICT DO NOTHING). Realtime publish +// and channel dispatch must operate on this set only. +type InsertedDelivery struct { + ID string + UserID int + ProfileID string + CreatedAt time.Time +} diff --git a/internal/notifications/settings.go b/internal/notifications/settings.go new file mode 100644 index 00000000..ce61cabd --- /dev/null +++ b/internal/notifications/settings.go @@ -0,0 +1,199 @@ +package notifications + +import ( + "context" + "strconv" + "strings" + "sync" + "time" +) + +// SettingReader reads live server settings. Satisfied by +// catalog.ServerSettingsRepo and catalog.EncryptedSettingsRepo; declared +// locally to avoid a catalog dependency. +type SettingReader interface { + Get(ctx context.Context, key string) (string, error) +} + +// Server-setting keys for the notification system. All keys are live (no +// restart required): consumers read them through Settings, which caches reads +// briefly. The enabled flags default to on and act as kill switches; flood +// safety comes from per-library seed markers, not from staged flag flips. +const ( + SettingReleaseEventsEnabled = "notifications.release_events_enabled" + SettingFanoutEnabled = "notifications.fanout_enabled" + SettingUIEnabled = "notifications.ui_enabled" + SettingFanoutSettleSeconds = "notifications.fanout.settle_seconds" + SettingFanoutMaxSeriesBurst = "notifications.fanout.max_series_burst" + SettingFanoutMaxEventAge = "notifications.fanout.max_event_age_hours" + SettingRetentionReadDays = "notifications.retention.read_days" + SettingRetentionUnreadDays = "notifications.retention.unread_days" + SettingRetentionEventDays = "notifications.retention.event_days" + + SettingWebhooksEnabled = "notifications.webhooks_enabled" + SettingWebhooksMaxPerProfile = "notifications.webhooks.max_per_profile" + SettingWebhooksAllowPrivate = "notifications.webhooks.allow_private_destinations" + SettingWebhooksRatePerMinute = "notifications.webhooks.deliveries_per_minute_per_profile" +) + +const ( + defaultSettleSeconds = 30 + defaultMaxSeriesBurst = 3 + defaultMaxEventAgeHours = 72 + defaultRetentionReadDays = 90 + defaultRetentionUnread = 180 + defaultRetentionEventDays = 30 + + settingsCacheTTL = 15 * time.Second +) + +// Settings exposes typed accessors over live server settings with a short +// read-through cache so hot paths (ingest, fanout loop) do not hit the +// settings table on every call. +type Settings struct { + reader SettingReader + now func() time.Time + + mu sync.Mutex + cache map[string]settingsCacheEntry +} + +type settingsCacheEntry struct { + value string + fetchedAt time.Time +} + +// NewSettings creates a Settings facade. reader may be nil, in which case all +// accessors return their defaults. +func NewSettings(reader SettingReader) *Settings { + return &Settings{ + reader: reader, + now: time.Now, + cache: make(map[string]settingsCacheEntry), + } +} + +func (s *Settings) raw(ctx context.Context, key string) string { + if s == nil || s.reader == nil { + return "" + } + s.mu.Lock() + entry, ok := s.cache[key] + if ok && s.now().Sub(entry.fetchedAt) < settingsCacheTTL { + s.mu.Unlock() + return entry.value + } + s.mu.Unlock() + + value, err := s.reader.Get(ctx, key) + if err != nil { + // Fall back to the stale cached value (if any) rather than flapping + // to defaults on transient DB errors. + if ok { + return entry.value + } + return "" + } + + s.mu.Lock() + s.cache[key] = settingsCacheEntry{value: value, fetchedAt: s.now()} + s.mu.Unlock() + return value +} + +func (s *Settings) boolSetting(ctx context.Context, key string, fallback bool) bool { + raw := strings.TrimSpace(strings.ToLower(s.raw(ctx, key))) + switch raw { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + default: + return fallback + } +} + +func (s *Settings) intSetting(ctx context.Context, key string, fallback, min, max int) int { + raw := strings.TrimSpace(s.raw(ctx, key)) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil || value < min || value > max { + return fallback + } + return value +} + +// ReleaseEventsEnabled gates release-event creation during ingest. +func (s *Settings) ReleaseEventsEnabled(ctx context.Context) bool { + return s.boolSetting(ctx, SettingReleaseEventsEnabled, true) +} + +// FanoutEnabled gates the fanout worker. +func (s *Settings) FanoutEnabled(ctx context.Context) bool { + return s.boolSetting(ctx, SettingFanoutEnabled, true) +} + +// UIEnabled gates the inbox/preferences API surface advertised to clients. +func (s *Settings) UIEnabled(ctx context.Context) bool { + return s.boolSetting(ctx, SettingUIEnabled, true) +} + +// SettleDelay is how old a release event must be before the fanout worker +// claims it, so one scan's burst for a series lands in the same claim batch. +func (s *Settings) SettleDelay(ctx context.Context) time.Duration { + return time.Duration(s.intSetting(ctx, SettingFanoutSettleSeconds, defaultSettleSeconds, 0, 3600)) * time.Second +} + +// MaxSeriesBurst is the per-(library, series) cap on fanned-out events per +// claim batch; the remainder is suppressed with suppressed_reason. +func (s *Settings) MaxSeriesBurst(ctx context.Context) int { + return s.intSetting(ctx, SettingFanoutMaxSeriesBurst, defaultMaxSeriesBurst, 1, 1000) +} + +// MaxEventAge bounds how old an unprocessed release event may be and still +// fan out. Events past the horizon (fanout disabled for a stretch, extended +// downtime) are suppressed as stale instead of being delivered long after the +// fact; retention deletes them. +func (s *Settings) MaxEventAge(ctx context.Context) time.Duration { + return time.Duration(s.intSetting(ctx, SettingFanoutMaxEventAge, defaultMaxEventAgeHours, 1, 24*365)) * time.Hour +} + +// ReadRetentionDays bounds how long read inbox rows are kept. +func (s *Settings) ReadRetentionDays(ctx context.Context) int { + return s.intSetting(ctx, SettingRetentionReadDays, defaultRetentionReadDays, 1, 3650) +} + +// UnreadRetentionDays bounds how long unread inbox rows are kept. +func (s *Settings) UnreadRetentionDays(ctx context.Context) int { + return s.intSetting(ctx, SettingRetentionUnreadDays, defaultRetentionUnread, 1, 3650) +} + +// EventRetentionDays bounds how long processed release events are kept for +// debugging. +func (s *Settings) EventRetentionDays(ctx context.Context) int { + return s.intSetting(ctx, SettingRetentionEventDays, defaultRetentionEventDays, 1, 3650) +} + +// WebhooksEnabled gates the outbound webhooks channel (kill switch). +func (s *Settings) WebhooksEnabled(ctx context.Context) bool { + return s.boolSetting(ctx, SettingWebhooksEnabled, true) +} + +// WebhooksMaxPerProfile caps how many webhooks one profile may create. +func (s *Settings) WebhooksMaxPerProfile(ctx context.Context) int { + return s.intSetting(ctx, SettingWebhooksMaxPerProfile, 10, 1, 100) +} + +// WebhooksAllowPrivateDestinations disables the private-destination guard. +// Intended only for development environments. +func (s *Settings) WebhooksAllowPrivateDestinations(ctx context.Context) bool { + return s.boolSetting(ctx, SettingWebhooksAllowPrivate, false) +} + +// WebhooksDeliveriesPerMinute is the per-profile webhook delivery rate limit. +// Over-limit notifications stay in the inbox; webhooks just don't fire. +func (s *Settings) WebhooksDeliveriesPerMinute(ctx context.Context) int { + return s.intSetting(ctx, SettingWebhooksRatePerMinute, 60, 1, 10000) +} diff --git a/internal/notifications/system.go b/internal/notifications/system.go new file mode 100644 index 00000000..ec69e2c2 --- /dev/null +++ b/internal/notifications/system.go @@ -0,0 +1,643 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "sync" + "time" + + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/secret" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +// UserLister enumerates login accounts for the interest backfill. Satisfied +// by *auth.UserRepository. +type UserLister interface { + List(ctx context.Context) ([]*models.User, error) +} + +// ImageURLResolver presigns stored image paths into client-fetchable URLs. +// Satisfied by *catalog.DetailService. +type ImageURLResolver interface { + PresignImageURL(ctx context.Context, path, imageType, size string) string +} + +// System bundles the user-facing release-notification services: availability +// detection, interest maintenance, fanout, inbox repositories, and websocket +// tickets. It is distinct from the operational Hub in hub.go. +type System struct { + Settings *Settings + Releases *ReleaseRepository + Interests *InterestRepository + Deliveries *DeliveryRepository + Preferences *PreferencesRepository + Detector *AvailabilityDetector + Interest *InterestUpdater + Fanout *FanoutWorker + Tickets TicketStore + // Webhooks is nil when no at-rest cipher is configured (webhook URLs are + // credentials and must not be stored in plaintext). + Webhooks *WebhookService + // WebPush is nil when the settings store is not writable (VAPID keys + // could not be provisioned). + WebPush *WebPushService + + webhookRepo *WebhookRepository + webhookDispatcher *WebhookDispatcher + webhookRetry *WebhookRetryWorker + webPushRepo *WebPushRepository + webPushDispatcher *WebPushDispatcher + + pool *pgxpool.Pool + stores userstore.UserStoreProvider + users UserLister + images ImageURLResolver + logger *slog.Logger + wg sync.WaitGroup +} + +// NewSystem wires the notification system. hub may be nil (no realtime +// publishing); redisClient may be nil (in-memory websocket tickets). +func NewSystem( + pool *pgxpool.Pool, + settingsReader SettingReader, + stores userstore.UserStoreProvider, + scopes ScopeResolver, + users UserLister, + hub *evt.Hub, + redisClient *redis.Client, + cipher *secret.Cipher, +) *System { + settings := NewSettings(settingsReader) + releases := NewReleaseRepository(pool) + interests := NewInterestRepository(pool) + deliveries := NewDeliveryRepository(pool) + preferences := NewPreferencesRepository(pool) + + wsDispatcher := NewWebsocketDispatcher(hub) + dispatchers := []Dispatcher{wsDispatcher} + + // Outbound webhooks require the at-rest cipher: destination URLs are + // bearer credentials (Discord) and must never be stored in plaintext. + var webhookRepo *WebhookRepository + var webhookService *WebhookService + var webhookDispatcher *WebhookDispatcher + var webhookRetry *WebhookRetryWorker + var sender *webhookSender + if cipher != nil { + webhookRepo = NewWebhookRepository(pool) + sender = newWebhookSender(webhookRepo, deliveries, cipher, settings, hub) + webhookService = newWebhookService(webhookRepo, cipher, settings, sender) + webhookDispatcher = newWebhookDispatcher(sender) + webhookRetry = newWebhookRetryWorker(sender) + dispatchers = append(dispatchers, webhookDispatcher) + } + + // Web push needs a writable settings store to self-provision its VAPID + // keypair. The reader main passes is the encrypted settings repo, which + // also writes; tests may pass a read-only stub. + var webPushRepo *WebPushRepository + var webPushService *WebPushService + var webPushDispatcher *WebPushDispatcher + var webPushSenderInst *webPushSender + if writer, ok := settingsReader.(SettingWriter); ok && writer != nil { + webPushRepo = NewWebPushRepository(pool) + webPushService = newWebPushService(webPushRepo, settings, writer) + webPushSenderInst = newWebPushSender(webPushRepo, deliveries, webPushService, settings) + webPushDispatcher = newWebPushDispatcher(webPushSenderInst) + dispatchers = append(dispatchers, webPushDispatcher) + } + + fanout := NewFanoutWorker(pool, releases, interests, deliveries, preferences, settings, NewMultiDispatcher(dispatchers...)) + if webhookRepo != nil { + fanout.SetWebhookOutbox(webhookRepo, newProfileRateLimiter()) + } + if webPushRepo != nil { + fanout.SetWebPushOutbox(webPushRepo) + } + detector := NewAvailabilityDetector(releases, settings) + detector.SetFanoutNudge(fanout.Nudge) + interest := NewInterestUpdater(pool, interests, stores, scopes) + + system := &System{ + Settings: settings, + Releases: releases, + Interests: interests, + Deliveries: deliveries, + Preferences: preferences, + Detector: detector, + Interest: interest, + Fanout: fanout, + Tickets: NewTicketStore(redisClient), + Webhooks: webhookService, + WebPush: webPushService, + webhookRepo: webhookRepo, + webhookDispatcher: webhookDispatcher, + webhookRetry: webhookRetry, + webPushRepo: webPushRepo, + webPushDispatcher: webPushDispatcher, + pool: pool, + stores: stores, + users: users, + logger: slog.Default().With("component", "notifications.system"), + } + wsDispatcher.payload = system.PayloadForRow + if sender != nil { + sender.payload = system.PayloadForRow + } + if webPushSenderInst != nil { + webPushSenderInst.payload = system.PayloadForRow + } + return system +} + +// SetImageResolver wires presigned poster URLs into notification payloads. +// Optional; without it clients fall back to thumbhash placeholders. +func (s *System) SetImageResolver(resolver ImageURLResolver) { + if s != nil { + s.images = resolver + } +} + +// PayloadForRow converts a row to its wire shape, attaching a presigned +// poster URL when an image resolver is configured. +func (s *System) PayloadForRow(ctx context.Context, row DeliveryRow) DeliveryRowPayload { + payload := PayloadForRow(row) + if s != nil && s.images != nil && row.PosterPath != "" { + payload.PosterURL = s.images.PresignImageURL(ctx, row.PosterPath, "poster", "") + } + return payload +} + +// PayloadsForRows converts rows to their wire shape with poster URLs. +func (s *System) PayloadsForRows(ctx context.Context, rows []DeliveryRow) []DeliveryRowPayload { + payloads := make([]DeliveryRowPayload, 0, len(rows)) + for _, row := range rows { + payloads = append(payloads, s.PayloadForRow(ctx, row)) + } + return payloads +} + +// Start launches the fanout worker, interest updater, and (when configured) +// the webhook dispatch pool and retry worker under ctx. +func (s *System) Start(ctx context.Context) { + if s == nil { + return + } + s.wg.Add(2) + go func() { + defer s.wg.Done() + s.Fanout.Run(ctx) + }() + go func() { + defer s.wg.Done() + s.Interest.Run(ctx) + }() + if s.webhookDispatcher != nil { + s.wg.Add(2) + go func() { + defer s.wg.Done() + s.webhookDispatcher.Run(ctx) + }() + go func() { + defer s.wg.Done() + s.webhookRetry.Run(ctx) + }() + } + if s.webPushDispatcher != nil { + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.webPushDispatcher.Run(ctx) + }() + // Provision the VAPID keypair eagerly so a broken settings store + // surfaces at startup instead of on the first subscribe, and the + // capability endpoint never pays the generation latency. + go func() { + if _, err := s.WebPush.PublicKey(ctx); err != nil && ctx.Err() == nil { + s.logger.Error("web push VAPID provisioning failed", "error", err) + } + }() + } +} + +// Wait blocks until the background loops exit (after their context is +// canceled), so shutdown can drain in-flight work. +func (s *System) Wait() { + if s != nil { + s.wg.Wait() + } +} + +// PurgeProfile removes all notification state for a deleted profile. +// Profiles may live in per-user SQLite stores, so Postgres cascades cannot +// cover this. +func (s *System) PurgeProfile(ctx context.Context, profileID string) error { + if s == nil || profileID == "" { + return nil + } + if err := s.Interests.DeleteAllForProfile(ctx, profileID); err != nil { + return fmt.Errorf("purge interest rows: %w", err) + } + if err := s.Deliveries.DeleteAllForProfile(ctx, profileID); err != nil { + return fmt.Errorf("purge deliveries: %w", err) + } + if err := s.Preferences.DeleteForProfile(ctx, profileID); err != nil { + return fmt.Errorf("purge preferences: %w", err) + } + if s.webhookRepo != nil { + if err := s.webhookRepo.DeleteAllForProfile(ctx, profileID); err != nil { + return fmt.Errorf("purge webhooks: %w", err) + } + } + if s.webPushRepo != nil { + if err := s.webPushRepo.DeleteAllForProfile(ctx, profileID); err != nil { + return fmt.Errorf("purge web push subscriptions: %w", err) + } + } + return nil +} + +// SeedAvailability inserts episode_availability for every currently playable +// episode without creating release events, then writes the per-library seed +// markers. Idempotent and rerunnable; it exists for libraries that predate +// the notifications feature. +// +// Library selection is load-bearing for flood and loss safety: +// - already-seeded libraries are skipped: availability there is owned by +// the scan-end detector, and a silent (emitEvents=false) insert racing an +// in-flight scan would permanently suppress the claimed episode's release +// event; +// - never-fully-scanned libraries are skipped: their catalog is empty or +// partial, so seed-marking them now would make the first real scan emit +// release events for the entire back catalog. The detector writes their +// marker when that first full scan completes. +func (s *System) SeedAvailability(ctx context.Context, progress func(percent int, message string)) error { + report := func(percent int, message string) { + if progress != nil { + progress(percent, message) + } + } + rows, err := s.pool.Query(ctx, ` + SELECT mf.id + FROM media_folders mf + WHERE mf.last_scanned_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM notification_library_seed_state seed WHERE seed.library_id = mf.id + ) + ORDER BY mf.id`) + if err != nil { + return fmt.Errorf("list libraries: %w", err) + } + libraryIDs := make([]int, 0, 8) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return fmt.Errorf("scan library id: %w", err) + } + libraryIDs = append(libraryIDs, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + totalSeeded := 0 + for i, libraryID := range libraryIDs { + if ctx.Err() != nil { + return ctx.Err() + } + inserted, _, err := s.Releases.RecordAvailabilityForLibrary(ctx, libraryID, false) + if err != nil { + return fmt.Errorf("seed library %d: %w", libraryID, err) + } + if err := s.Releases.MarkLibrarySeeded(ctx, libraryID); err != nil { + return fmt.Errorf("mark library %d seeded: %w", libraryID, err) + } + totalSeeded += inserted + report((i+1)*100/max(len(libraryIDs), 1), + fmt.Sprintf("Seeded library %d (%d new availability rows)", libraryID, inserted)) + } + s.logger.Info("availability seeding completed", + "libraries", len(libraryIDs), "availability_rows", totalSeeded) + return nil +} + +const interestRebuildTask = "interest_rebuild" + +// RebuildInterest incrementally rebuilds profile_series_interest from +// favorites, watchlist, and watch progress for every profile. Checkpointed +// per profile so a crash resumes with at most one profile of repeated work; +// recomputes are idempotent upserts. Completed runs reset and start over +// (the task doubles as periodic drift repair). +func (s *System) RebuildInterest(ctx context.Context, progress func(percent int, message string)) error { + report := func(percent int, message string) { + if progress != nil { + progress(percent, message) + } + } + if s.users == nil { + return fmt.Errorf("interest rebuild requires a user lister") + } + + checkpoint, completedAt, err := s.loadBackfillCheckpoint(ctx, interestRebuildTask) + if err != nil { + return err + } + if completedAt != nil { + // Start a fresh repair pass. + checkpoint = "" + if err := s.resetBackfillCheckpoint(ctx, interestRebuildTask); err != nil { + return err + } + } + + users, err := s.users.List(ctx) + if err != nil { + return fmt.Errorf("list users: %w", err) + } + sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID }) + + processed := 0 + for userIdx, user := range users { + if ctx.Err() != nil { + return ctx.Err() + } + store, err := s.stores.ForUser(ctx, user.ID) + if err != nil { + s.logger.Warn("interest rebuild: open user store failed", "user_id", user.ID, "error", err) + continue + } + profiles, err := store.ListProfiles(ctx) + if err != nil { + s.logger.Warn("interest rebuild: list profiles failed", "user_id", user.ID, "error", err) + continue + } + sort.Slice(profiles, func(i, j int) bool { return profiles[i].ID < profiles[j].ID }) + + for _, profile := range profiles { + key := backfillKey(user.ID, profile.ID) + if checkpoint != "" && key <= checkpoint { + continue + } + if err := s.rebuildProfileInterest(ctx, store, user.ID, profile.ID); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + s.logger.Warn("interest rebuild: profile failed", + "user_id", user.ID, "profile_id", profile.ID, "error", err) + } + if err := s.saveBackfillCheckpoint(ctx, interestRebuildTask, key); err != nil { + return err + } + processed++ + } + report((userIdx+1)*100/max(len(users), 1), + fmt.Sprintf("Rebuilt interest for %d profiles", processed)) + } + + if err := s.completeBackfillCheckpoint(ctx, interestRebuildTask); err != nil { + return err + } + s.logger.Info("interest rebuild completed", "profiles", processed) + return nil +} + +// rebuildProfileInterest recomputes every series the profile has any +// relationship with (favorites, watchlist, progress). +func (s *System) rebuildProfileInterest(ctx context.Context, store userstore.UserStore, userID int, profileID string) error { + const pageSize = 500 + itemIDs := make(map[string]struct{}, 64) + + for offset := 0; ; offset += pageSize { + favorites, err := store.ListFavorites(ctx, profileID, pageSize, offset) + if err != nil { + return fmt.Errorf("list favorites: %w", err) + } + for _, favorite := range favorites { + itemIDs[favorite.MediaItemID] = struct{}{} + } + if len(favorites) < pageSize { + break + } + } + for offset := 0; ; offset += pageSize { + watchlist, err := store.ListWatchlist(ctx, profileID, pageSize, offset) + if err != nil { + return fmt.Errorf("list watchlist: %w", err) + } + for _, entry := range watchlist { + itemIDs[entry.MediaItemID] = struct{}{} + } + if len(watchlist) < pageSize { + break + } + } + for offset := 0; ; offset += pageSize { + progress, err := store.ListProgress(ctx, profileID, "", pageSize, offset) + if err != nil { + return fmt.Errorf("list progress: %w", err) + } + for _, entry := range progress { + itemIDs[entry.MediaItemID] = struct{}{} + } + if len(progress) < pageSize { + break + } + } + if len(itemIDs) == 0 { + return nil + } + + seriesIDs, err := s.batchResolveSeries(ctx, itemIDs) + if err != nil { + return err + } + for seriesID := range seriesIDs { + if ctx.Err() != nil { + return ctx.Err() + } + if err := s.Interest.RecomputeSeries(ctx, userID, profileID, seriesID); err != nil { + s.logger.Warn("interest rebuild: series recompute failed", + "user_id", userID, "profile_id", profileID, "series_id", seriesID, "error", err) + } + } + return nil +} + +// batchResolveSeries maps item IDs (episodes, seasons, series) to their +// series IDs; movies resolve to nothing. +func (s *System) batchResolveSeries(ctx context.Context, itemIDs map[string]struct{}) (map[string]struct{}, error) { + ids := make([]string, 0, len(itemIDs)) + for id := range itemIDs { + ids = append(ids, id) + } + seriesIDs := make(map[string]struct{}, len(ids)) + const chunkSize = 500 + for start := 0; start < len(ids); start += chunkSize { + end := min(start+chunkSize, len(ids)) + rows, err := s.pool.Query(ctx, ` + SELECT series_id FROM episodes WHERE content_id = ANY($1) + UNION + SELECT series_id FROM seasons WHERE content_id = ANY($1) + UNION + SELECT content_id FROM media_items WHERE content_id = ANY($1) AND type = 'series'`, + ids[start:end]) + if err != nil { + return nil, fmt.Errorf("resolve series ids: %w", err) + } + for rows.Next() { + var seriesID string + if err := rows.Scan(&seriesID); err != nil { + rows.Close() + return nil, fmt.Errorf("scan series id: %w", err) + } + if seriesID != "" { + seriesIDs[seriesID] = struct{}{} + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + } + return seriesIDs, nil +} + +// RetentionStats reports what a retention pass removed. +type RetentionStats struct { + DeliveriesDeleted int64 + EventsDeleted int64 + StaleEventsDeleted int64 + InterestPruned int64 + WebhookAttemptsDeleted int64 + WebPushAttemptsDeleted int64 +} + +// RunRetention applies the retention policy: read deliveries past the read +// window, unread past the unread window, processed release events past the +// debug window, unprocessed release events past the fanout staleness horizon, +// and inert interest rows. +func (s *System) RunRetention(ctx context.Context) (RetentionStats, error) { + var stats RetentionStats + now := time.Now().UTC() + readCutoff := now.AddDate(0, 0, -s.Settings.ReadRetentionDays(ctx)) + unreadCutoff := now.AddDate(0, 0, -s.Settings.UnreadRetentionDays(ctx)) + eventCutoff := now.AddDate(0, 0, -s.Settings.EventRetentionDays(ctx)) + + deleted, err := s.Deliveries.DeleteOld(ctx, readCutoff, unreadCutoff) + if err != nil { + return stats, fmt.Errorf("prune deliveries: %w", err) + } + stats.DeliveriesDeleted = deleted + + events, err := s.Releases.DeleteProcessedBefore(ctx, eventCutoff) + if err != nil { + return stats, fmt.Errorf("prune release events: %w", err) + } + stats.EventsDeleted = events + + // Unprocessed events accumulate without bound when fanout is disabled + // while availability detection keeps emitting; the fanout worker would + // suppress them as stale anyway, so retention reclaims them directly. + staleEvents, err := s.Releases.DeleteUnprocessedBefore(ctx, now.Add(-s.Settings.MaxEventAge(ctx))) + if err != nil { + return stats, fmt.Errorf("prune stale release events: %w", err) + } + stats.StaleEventsDeleted = staleEvents + + pruned, err := s.Interests.PruneInert(ctx) + if err != nil { + return stats, fmt.Errorf("prune interest rows: %w", err) + } + stats.InterestPruned = pruned + + if s.webhookRepo != nil { + attempts, err := s.webhookRepo.DeleteOldAttempts(ctx, now) + if err != nil { + return stats, fmt.Errorf("prune webhook attempts: %w", err) + } + stats.WebhookAttemptsDeleted = attempts + } + if s.webPushRepo != nil { + attempts, err := s.webPushRepo.DeleteOldAttempts(ctx, now) + if err != nil { + return stats, fmt.Errorf("prune web push attempts: %w", err) + } + stats.WebPushAttemptsDeleted = attempts + } + return stats, nil +} + +func backfillKey(userID int, profileID string) string { + return fmt.Sprintf("%010d|%s", userID, profileID) +} + +func (s *System) loadBackfillCheckpoint(ctx context.Context, task string) (string, *time.Time, error) { + var key *string + var completedAt *time.Time + err := s.pool.QueryRow(ctx, + `SELECT last_processed_key, completed_at FROM notification_backfill_state WHERE task = $1`, + task, + ).Scan(&key, &completedAt) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil, nil + } + if err != nil { + return "", nil, fmt.Errorf("load backfill checkpoint: %w", err) + } + checkpoint := "" + if key != nil { + checkpoint = *key + } + return checkpoint, completedAt, nil +} + +func (s *System) saveBackfillCheckpoint(ctx context.Context, task, key string) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO notification_backfill_state (task, last_processed_key, started_at, updated_at) + VALUES ($1, $2, now(), now()) + ON CONFLICT (task) DO UPDATE SET + last_processed_key = EXCLUDED.last_processed_key, + updated_at = now()`, + task, key) + if err != nil { + return fmt.Errorf("save backfill checkpoint: %w", err) + } + return nil +} + +func (s *System) resetBackfillCheckpoint(ctx context.Context, task string) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO notification_backfill_state (task, last_processed_key, started_at, updated_at, completed_at) + VALUES ($1, NULL, now(), now(), NULL) + ON CONFLICT (task) DO UPDATE SET + last_processed_key = NULL, + started_at = now(), + updated_at = now(), + completed_at = NULL`, + task) + if err != nil { + return fmt.Errorf("reset backfill checkpoint: %w", err) + } + return nil +} + +func (s *System) completeBackfillCheckpoint(ctx context.Context, task string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE notification_backfill_state SET completed_at = now(), updated_at = now() WHERE task = $1`, + task) + if err != nil { + return fmt.Errorf("complete backfill checkpoint: %w", err) + } + return nil +} diff --git a/internal/notifications/webhook_dispatcher.go b/internal/notifications/webhook_dispatcher.go new file mode 100644 index 00000000..ddf434a8 --- /dev/null +++ b/internal/notifications/webhook_dispatcher.go @@ -0,0 +1,140 @@ +package notifications + +import ( + "context" + "log/slog" + "sync" + "time" +) + +const ( + webhookDispatchWorkers = 16 + webhookDispatchQueue = 256 + webhookRetryInterval = 30 * time.Second + webhookRetryClaimLimit = 50 +) + +// WebhookDispatcher implements the channel Dispatcher interface for outbound +// webhooks. Dispatch never blocks the fanout loop on destination HTTP: it +// hands the delivery ID to a bounded worker pool that claims the delivery's +// pending outbox attempts and sends them. A full queue simply drops the +// hand-off — the durable `pending` rows are picked up by the retry worker's +// outbox recovery sweep, so delivery is delayed, never lost. +type WebhookDispatcher struct { + sender *webhookSender + queue chan string + logger *slog.Logger +} + +func newWebhookDispatcher(sender *webhookSender) *WebhookDispatcher { + return &WebhookDispatcher{ + sender: sender, + queue: make(chan string, webhookDispatchQueue), + logger: slog.Default().With("component", "notifications.webhooks.dispatch"), + } +} + +// Dispatch queues the delivery's webhook attempts for immediate send. +func (d *WebhookDispatcher) Dispatch(_ context.Context, delivery DeliveryRow) error { + if d == nil { + return nil + } + if delivery.Type == DeliveryTypeWebhookAutoDisabled { + // Type deny list: an auto-disable notice must never re-dispatch as a + // webhook, or a broken webhook would loop forever. + return nil + } + select { + case d.queue <- delivery.ID: + default: + d.logger.Warn("webhook dispatch queue full; deferring to retry worker", + "delivery_id", delivery.ID) + } + return nil +} + +// Run consumes the dispatch queue with a bounded worker pool until ctx is +// canceled. One slow destination cannot block other deliveries. +func (d *WebhookDispatcher) Run(ctx context.Context) { + var wg sync.WaitGroup + for range webhookDispatchWorkers { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case deliveryID := <-d.queue: + d.processDelivery(ctx, deliveryID) + } + } + }() + } + wg.Wait() +} + +func (d *WebhookDispatcher) processDelivery(ctx context.Context, deliveryID string) { + attempts, err := d.sender.webhooks.ClaimPendingForDelivery(ctx, deliveryID) + if err != nil { + if ctx.Err() == nil { + d.logger.Warn("webhook attempt claim failed", "delivery_id", deliveryID, "error", err) + } + return + } + for _, attempt := range attempts { + if ctx.Err() != nil { + return + } + d.sender.processAttempt(ctx, attempt) + } +} + +// WebhookRetryWorker drains due retries and recovers stale pending outbox +// rows whose post-commit dispatch never ran (process crash between the fanout +// commit and dispatch). +type WebhookRetryWorker struct { + sender *webhookSender + logger *slog.Logger +} + +func newWebhookRetryWorker(sender *webhookSender) *WebhookRetryWorker { + return &WebhookRetryWorker{ + sender: sender, + logger: slog.Default().With("component", "notifications.webhooks.retry"), + } +} + +// Run polls for due attempts until ctx is canceled. +func (w *WebhookRetryWorker) Run(ctx context.Context) { + ticker := time.NewTicker(webhookRetryInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + if !w.sender.settings.WebhooksEnabled(ctx) { + continue + } + for { + attempts, err := w.sender.webhooks.ClaimDue(ctx, webhookRetryClaimLimit) + if err != nil { + if ctx.Err() == nil { + w.logger.Warn("webhook retry claim failed", "error", err) + } + break + } + if len(attempts) == 0 { + break + } + for _, attempt := range attempts { + if ctx.Err() != nil { + return + } + w.sender.processAttempt(ctx, attempt) + } + } + } +} diff --git a/internal/notifications/webhook_guard.go b/internal/notifications/webhook_guard.go new file mode 100644 index 00000000..b5bbe8b5 --- /dev/null +++ b/internal/notifications/webhook_guard.go @@ -0,0 +1,132 @@ +package notifications + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// webhookDeniedNetworks are the private/special ranges webhook destinations +// must never resolve to (docs/superpowers/plans/notifications/04, "Trust +// Model"). IPv4-mapped IPv6 addresses are unwrapped before checking, so the +// IPv4 entries also cover ::ffff:0:0/96 bypass attempts. +var webhookDeniedNetworks = func() []*net.IPNet { + cidrs := []string{ + // IPv4 private/special. + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", // CGNAT (RFC 6598) + "127.0.0.0/8", // loopback + "169.254.0.0/16", // link-local + "172.16.0.0/12", + "192.0.0.0/24", // IETF protocol assignments + "192.0.2.0/24", // TEST-NET-1 + "198.51.100.0/24", // TEST-NET-2 + "203.0.113.0/24", // TEST-NET-3 + "192.88.99.0/24", // deprecated 6to4 anycast + "192.168.0.0/16", + "198.18.0.0/15", // benchmarking (RFC 2544) + "224.0.0.0/4", // multicast + "240.0.0.0/4", // reserved future use (incl. broadcast) + // IPv6 private/special. + "::/128", // unspecified + "::1/128", // loopback + "fc00::/7", // ULA + "fe80::/10", // link-local + "2001:db8::/32", // documentation + "64:ff9b::/96", // NAT64 + } + networks := make([]*net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + panic(fmt.Sprintf("invalid webhook deny CIDR %q: %v", cidr, err)) + } + networks = append(networks, network) + } + return networks +}() + +// webhookIPAllowed reports whether a resolved destination IP is outside every +// denied range. v4-mapped IPv6 addresses are unwrapped and re-checked against +// the IPv4 deny set — a literal ::ffff:127.0.0.1 reaches loopback while +// bypassing naive IPv4-only checks. +func webhookIPAllowed(ip net.IP) bool { + if ip == nil { + return false + } + if v4 := ip.To4(); v4 != nil { + ip = v4 + } + for _, network := range webhookDeniedNetworks { + if network.Contains(ip) { + return false + } + } + return true +} + +// ValidateWebhookURL enforces the destination guardrails the profile cannot +// opt out of: HTTPS only, a well-formed host, and (unless the admin enabled +// private destinations for development) resolution to public addresses only. +// Returns the host for the denormalized url_host column. +func ValidateWebhookURL(rawURL string, allowPrivate bool) (host string, err error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "", fmt.Errorf("invalid URL") + } + if parsed.Scheme != "https" { + return "", fmt.Errorf("webhook URLs must use https") + } + host = parsed.Hostname() + if host == "" { + return "", fmt.Errorf("webhook URL has no host") + } + if len(host) > 253 { + return "", fmt.Errorf("webhook URL host is too long") + } + if parsed.User != nil { + return "", fmt.Errorf("webhook URLs must not embed credentials") + } + if allowPrivate { + return host, nil + } + + if ip := net.ParseIP(host); ip != nil { + if !webhookIPAllowed(ip) { + return "", fmt.Errorf("webhook destinations on private or special-use networks are not allowed") + } + return host, nil + } + + // Registration-time resolution check. Delivery-time re-validation happens + // in the HTTP client's dialer (DNS rebinding mitigation), so a host that + // later starts resolving privately is still refused. + addrs, err := net.LookupIP(host) + if err != nil { + return "", fmt.Errorf("webhook host could not be resolved") + } + for _, addr := range addrs { + if !webhookIPAllowed(addr) { + return "", fmt.Errorf("webhook destinations on private or special-use networks are not allowed") + } + } + return host, nil +} + +// discordWebhookURL matches Discord channel webhook endpoints for type +// auto-detection. +func discordWebhookURL(rawURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + switch host { + case "discord.com", "discordapp.com", "ptb.discord.com", "canary.discord.com": + default: + return false + } + return strings.HasPrefix(parsed.Path, "/api/webhooks/") +} diff --git a/internal/notifications/webhook_http.go b/internal/notifications/webhook_http.go new file mode 100644 index 00000000..a35443e4 --- /dev/null +++ b/internal/notifications/webhook_http.go @@ -0,0 +1,161 @@ +package notifications + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + webhookRequestTimeout = 10 * time.Second + webhookMaxRedirects = 3 + webhookUserAgent = "Silo-Webhook/1.0" +) + +// webhookSendResult is the structured outcome of one webhook POST. +type webhookSendResult struct { + OK bool + HTTPStatus int // 0 when no HTTP response was received + RetryAfter time.Duration // from a 429 Retry-After header, when present + Duration time.Duration + // Message is a short, non-sensitive diagnostic suitable for + // failure_message ("404 Not Found", "dns lookup failed", ...). Never + // includes URLs or payload contents. + Message string +} + +// newWebhookHTTPClient builds the delivery client: 10s total timeout, +// non-overridable TLS verification, bounded redirects, and a dialer Control +// hook that re-validates every resolved address at connect time (DNS +// rebinding mitigation — the guard runs on the address actually being +// connected to, each redirect hop included). +func newWebhookHTTPClient(allowPrivate func() bool) *http.Client { + dialer := &net.Dialer{ + Timeout: 5 * time.Second, + Control: func(network, address string, _ syscall.RawConn) error { + if allowPrivate != nil && allowPrivate() { + return nil + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("webhook dial: %w", err) + } + ip := net.ParseIP(host) + if ip == nil || !webhookIPAllowed(ip) { + return errPrivateDestination + } + return nil + }, + } + transport := &http.Transport{ + DialContext: dialer.DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 16, + IdleConnTimeout: 60 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: webhookRequestTimeout, + } + return &http.Client{ + Timeout: webhookRequestTimeout, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= webhookMaxRedirects { + return errors.New("too many redirects") + } + if req.URL.Scheme != "https" { + return errors.New("redirect to non-https destination") + } + return nil + }, + } +} + +var errPrivateDestination = errors.New("destination resolves to a private or special-use network") + +// sendWebhook POSTs body to url with the given extra headers and classifies +// the outcome. Success is any 2xx. The response body is drained (bounded) and +// discarded — Silo never consumes webhook responses for state. +func sendWebhook(ctx context.Context, client *http.Client, url string, body []byte, headers map[string]string) webhookSendResult { + started := time.Now() + result := func(ok bool, status int, message string) webhookSendResult { + return webhookSendResult{ + OK: ok, + HTTPStatus: status, + Duration: time.Since(started), + Message: message, + } + } + + ctx, cancel := context.WithTimeout(ctx, webhookRequestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return result(false, 0, "invalid webhook URL") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", webhookUserAgent) + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := client.Do(req) + if err != nil { + return result(false, 0, classifyWebhookError(err)) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return result(true, resp.StatusCode, "") + } + out := result(false, resp.StatusCode, http.StatusText(resp.StatusCode)) + if out.Message == "" { + out.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) + } else { + out.Message = fmt.Sprintf("%d %s", resp.StatusCode, out.Message) + } + if resp.StatusCode == http.StatusTooManyRequests { + if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 { + out.RetryAfter = time.Duration(seconds) * time.Second + } + } + return out +} + +// classifyWebhookError maps transport errors to short diagnostic classes. +// Messages must stay free of URLs and payload contents. +func classifyWebhookError(err error) string { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return "dns lookup failed" + } + if errors.Is(err, errPrivateDestination) { + return "destination resolves to a private network" + } + if errors.Is(err, context.DeadlineExceeded) { + return "request timed out" + } + message := err.Error() + switch { + case strings.Contains(message, "tls"): + return "tls handshake failed" + case strings.Contains(message, "connection refused"): + return "connection refused" + case strings.Contains(message, "too many redirects"): + return "too many redirects" + case strings.Contains(message, "redirect to non-https"): + return "redirect to non-https destination" + case strings.Contains(message, "timeout") || strings.Contains(message, "deadline"): + return "request timed out" + default: + return "connection failed" + } +} diff --git a/internal/notifications/webhook_logic_test.go b/internal/notifications/webhook_logic_test.go new file mode 100644 index 00000000..91315aa5 --- /dev/null +++ b/internal/notifications/webhook_logic_test.go @@ -0,0 +1,318 @@ +package notifications + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "strings" + "testing" + "time" +) + +func TestWebhookIPAllowed(t *testing.T) { + denied := []string{ + "0.1.2.3", + "10.1.2.3", + "100.64.0.1", // CGNAT + "127.0.0.1", // loopback + "169.254.1.1", // link-local + "172.16.0.1", // private + "192.0.0.1", // IETF + "192.0.2.1", // TEST-NET-1 + "198.51.100.1", // TEST-NET-2 + "203.0.113.1", // TEST-NET-3 + "192.88.99.1", // 6to4 anycast + "192.168.1.1", // private + "198.18.0.1", // benchmarking + "198.19.255.1", // benchmarking upper half + "224.0.0.1", // multicast + "255.255.255.255", + "::1", + "fc00::1", // ULA + "fe80::1", // link-local + "2001:db8::1", // documentation + "64:ff9b::7f00:1", // NAT64-mapped loopback + "::ffff:127.0.0.1", // v4-mapped loopback (the classic bypass) + "::ffff:192.168.0.5", // v4-mapped private + } + for _, raw := range denied { + if webhookIPAllowed(net.ParseIP(raw)) { + t.Errorf("webhookIPAllowed(%q) = true, want denied", raw) + } + } + + allowed := []string{"1.1.1.1", "8.8.8.8", "151.101.1.69", "2606:4700::1111"} + for _, raw := range allowed { + if !webhookIPAllowed(net.ParseIP(raw)) { + t.Errorf("webhookIPAllowed(%q) = false, want allowed", raw) + } + } +} + +func TestValidateWebhookURL(t *testing.T) { + if _, err := ValidateWebhookURL("http://example.com/hook", false); err == nil { + t.Fatal("plain http must be rejected") + } + if _, err := ValidateWebhookURL("https://user:pass@example.com/hook", false); err == nil { + t.Fatal("embedded credentials must be rejected") + } + if _, err := ValidateWebhookURL("https://127.0.0.1/hook", false); err == nil { + t.Fatal("loopback literal must be rejected") + } + if _, err := ValidateWebhookURL("https://[::ffff:127.0.0.1]/hook", false); err == nil { + t.Fatal("v4-mapped loopback literal must be rejected") + } + // allowPrivate bypasses the guard for dev environments. + host, err := ValidateWebhookURL("https://192.168.1.50/hook", true) + if err != nil || host != "192.168.1.50" { + t.Fatalf("allowPrivate bypass failed: %q %v", host, err) + } +} + +func TestDiscordWebhookURLDetection(t *testing.T) { + positives := []string{ + "https://discord.com/api/webhooks/123/abc", + "https://discordapp.com/api/webhooks/123/abc", + "https://ptb.discord.com/api/webhooks/123/abc", + } + for _, raw := range positives { + if !discordWebhookURL(raw) { + t.Errorf("discordWebhookURL(%q) = false, want true", raw) + } + } + negatives := []string{ + "https://hooks.slack.com/services/T/B/x", + "https://discord.com/channels/123", + "https://evil.com/api/webhooks/123/abc", + "https://discord.com.evil.com/api/webhooks/1/2", + } + for _, raw := range negatives { + if discordWebhookURL(raw) { + t.Errorf("discordWebhookURL(%q) = true, want false", raw) + } + } +} + +func webhookTestRow() DeliveryRow { + libraryID := 7 + seriesID := "series-123" + episodeID := "episode-456" + season := 2 + episode := 1 + return DeliveryRow{ + Delivery: Delivery{ + ID: "01DELIVERY", + ProfileID: "profile-1", + LibraryID: &libraryID, + SeriesID: &seriesID, + EpisodeID: &episodeID, + Type: DeliveryTypeEpisodeAvailable, + ReasonFlags: []byte(`{"favorite":true,"continue_watching":true}`), + CreatedAt: time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC), + }, + SeriesTitle: "Severance", + EpisodeTitle: "Hello, Ms. Cobel", + SeasonNumber: &season, + EpisodeNumber: &episode, + } +} + +func TestBuildDiscordWebhookPayload(t *testing.T) { + payload, err := BuildDiscordWebhookPayload(webhookTestRow(), false) + if err != nil { + t.Fatalf("build failed: %v", err) + } + var body struct { + Username string `json:"username"` + Embeds []struct { + Title string `json:"title"` + Color int `json:"color"` + Footer struct { + Text string `json:"text"` + } `json:"footer"` + Fields []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"fields"` + } `json:"embeds"` + } + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if body.Username != "Silo" || len(body.Embeds) != 1 { + t.Fatalf("unexpected body shape: %+v", body) + } + embed := body.Embeds[0] + if embed.Title != "Severance — S2 E1: Hello, Ms. Cobel" { + t.Fatalf("unexpected title %q", embed.Title) + } + if embed.Color != discordColorFavorite { + t.Fatalf("favorite reason must pick the favorite color, got %d", embed.Color) + } + if embed.Fields[0].Name != "Reason" || embed.Fields[0].Value != "Favorited & Continue Watching" { + t.Fatalf("unexpected reason field: %+v", embed.Fields[0]) + } + // The v1 privacy contract: no image, url, thumbnail, or avatar fields. + for _, forbidden := range []string{`"image"`, `"thumbnail"`, `"avatar_url"`, `"url"`} { + if strings.Contains(string(payload), forbidden) { + t.Fatalf("v1 Discord payload must not contain %s: %s", forbidden, payload) + } + } +} + +func TestBuildDiscordWebhookPayloadTestMarker(t *testing.T) { + payload, err := BuildDiscordWebhookPayload(webhookTestRow(), true) + if err != nil { + t.Fatalf("build failed: %v", err) + } + if !strings.Contains(string(payload), "Silo test notification") { + t.Fatal("test sends must be clearly marked in the footer") + } +} + +func TestDiscordTotalLimitTruncation(t *testing.T) { + row := webhookTestRow() + row.SeriesTitle = strings.Repeat("a", 300) // title gets clipped to 256 + payload, err := BuildDiscordWebhookPayload(row, false) + if err != nil { + t.Fatalf("build failed: %v", err) + } + var body struct { + Embeds []struct { + Title string `json:"title"` + } `json:"embeds"` + } + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatal(err) + } + if len(body.Embeds[0].Title) > discordTitleLimit { + t.Fatalf("title exceeds Discord limit: %d bytes", len(body.Embeds[0].Title)) + } + + embed := discordEmbed{ + Title: "t", + Description: strings.Repeat("d", 7000), + Fields: []discordEmbedField{ + {Name: "a", Value: strings.Repeat("x", 500)}, + {Name: "b", Value: strings.Repeat("y", 500)}, + }, + } + enforceDiscordTotalLimit(&embed) + if total := discordEmbedTotal(&embed); total > discordTotalLimit { + t.Fatalf("embed total %d exceeds %d after enforcement", total, discordTotalLimit) + } + if len(embed.Fields) != 2 { + t.Fatal("description must be truncated before fields are dropped") + } +} + +func TestGenericWebhookPayloadAndSignature(t *testing.T) { + row := webhookTestRow() + payload, err := BuildGenericWebhookPayload(row, "01HOOK", false) + if err != nil { + t.Fatalf("build failed: %v", err) + } + var body map[string]any + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if body["event"] != "notification.created" || body["version"] != float64(1) || body["test"] != false { + t.Fatalf("unexpected envelope: %v", body) + } + if body["delivery_id"] != "01DELIVERY" || body["webhook_id"] != "01HOOK" { + t.Fatalf("unexpected ids: %v", body) + } + series, seriesOK := body["series"].(map[string]any) + episode, episodeOK := body["episode"].(map[string]any) + if !seriesOK || !episodeOK || series["title"] != "Severance" || episode["season_number"] != float64(2) { + t.Fatalf("unexpected content: %v", body) + } + if strings.Contains(string(payload), "http") { + t.Fatal("generic payload must not contain any URLs") + } + + // Signature: deterministic, Stripe-style, verifiable from literal bytes. + const secretValue = "test-secret" + timestamp := int64(1714299296) + header := SignGenericWebhook(secretValue, timestamp, payload) + wantPrefix := fmt.Sprintf("t=%d,v1=", timestamp) + if !strings.HasPrefix(header, wantPrefix) { + t.Fatalf("unexpected signature header %q", header) + } + mac := hmac.New(sha256.New, []byte(secretValue)) + mac.Write([]byte("1714299296.")) + mac.Write(payload) + if header != wantPrefix+hex.EncodeToString(mac.Sum(nil)) { + t.Fatal("signature does not verify against literal body bytes") + } + if SignGenericWebhook(secretValue, timestamp, payload) != header { + t.Fatal("signature must be deterministic") + } +} + +func TestWebhookRetrySchedule(t *testing.T) { + // Cumulative schedule: 0, 30s, 2m, 10m, 30m, 2h, 6h, 12h, 18h, 24h. + total := time.Duration(0) + for attempt := 1; attempt < webhookMaxAttempts; attempt++ { + delay, ok := webhookRetryDelay(attempt) + if !ok { + t.Fatalf("schedule ended early at attempt %d", attempt) + } + total += delay + if total != webhookRetrySchedule[attempt] { + t.Fatalf("cumulative delay after attempt %d = %v, want %v", attempt, total, webhookRetrySchedule[attempt]) + } + } + if _, ok := webhookRetryDelay(webhookMaxAttempts); ok { + t.Fatal("attempt 10 must exhaust the schedule") + } + if total != 24*time.Hour { + t.Fatalf("schedule must span 24h, got %v", total) + } +} + +func TestRetryableHTTPStatus(t *testing.T) { + retryable := []int{0, 500, 502, 503, 408, 425, 429} + for _, status := range retryable { + if !retryableHTTPStatus(status) { + t.Errorf("status %d must be retryable", status) + } + } + nonRetryable := []int{400, 401, 403, 404, 410, 422} + for _, status := range nonRetryable { + if retryableHTTPStatus(status) { + t.Errorf("status %d must not be retryable", status) + } + } +} + +func TestWebhookMatchesReasons(t *testing.T) { + hook := Webhook{NotifyFavorites: true, NotifyWatchlist: false, NotifyContinueWatching: false, NotifyNextUp: false} + if !hook.MatchesReasons(ReasonFlags{Favorite: true, Watchlist: true}) { + t.Fatal("favorite reason must match a favorites-enabled webhook") + } + if hook.MatchesReasons(ReasonFlags{Watchlist: true}) { + t.Fatal("watchlist-only delivery must not match a favorites-only webhook") + } + if hook.MatchesReasons(ReasonFlags{}) { + t.Fatal("no reasons must never match") + } +} + +func TestProfileRateLimiter(t *testing.T) { + limiter := newProfileRateLimiter() + for i := 0; i < 3; i++ { + if !limiter.Allow("p1", 3) { + t.Fatalf("delivery %d must be allowed", i+1) + } + } + if limiter.Allow("p1", 3) { + t.Fatal("4th delivery within the window must be limited") + } + if !limiter.Allow("p2", 3) { + t.Fatal("limits must be per-profile") + } +} diff --git a/internal/notifications/webhook_payload_discord.go b/internal/notifications/webhook_payload_discord.go new file mode 100644 index 00000000..09fde7fb --- /dev/null +++ b/internal/notifications/webhook_payload_discord.go @@ -0,0 +1,200 @@ +package notifications + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// Discord embed accent colors per reason (decimal RGB), in reason precedence +// order: favorite, watchlist, continue_watching, next_up. +const ( + discordColorFavorite = 5814783 + discordColorWatchlist = 3066993 + discordColorContinueWatching = 15844367 + discordColorNextUp = 15158332 +) + +// Discord embed limits (enforced by the builder). +const ( + discordTitleLimit = 256 + discordDescriptionLimit = 4096 + discordFieldValueLimit = 1024 + discordFooterLimit = 2048 + discordTotalLimit = 6000 +) + +type discordEmbedField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline"` +} + +type discordEmbedFooter struct { + Text string `json:"text"` +} + +// discordEmbed deliberately has no image, url, or thumbnail fields: in v1 the +// embed must never name an origin Discord would fetch from, because that +// origin would be the user's server URL. The v1.5 CDN proxy re-enables images +// (docs/superpowers/plans/notifications/04, "Discord" payload notes). +type discordEmbed struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + Color int `json:"color"` + Footer *discordEmbedFooter `json:"footer,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Fields []discordEmbedField `json:"fields,omitempty"` +} + +type discordWebhookBody struct { + Embeds []discordEmbed `json:"embeds"` + Username string `json:"username"` +} + +// BuildDiscordWebhookPayload renders a delivery as a Discord embed. Pure +// function; enforces Discord's embed limits with the spec's truncation +// policy (description first, then drop fields right-to-left). +func BuildDiscordWebhookPayload(row DeliveryRow, test bool) ([]byte, error) { + flags := parseReasonFlags(row.ReasonFlags) + + title := discordEmbedTitle(row) + // Titles assembled from catalog metadata virtually never approach the + // limit, but Discord hard-rejects oversized embeds, so clip as a last + // resort even though the truncation policy prefers other fields. + title = truncateWithEllipsis(title, discordTitleLimit) + + description := "New episode available on Silo" + footerText := "Silo" + if row.SeriesTitle != "" { + footerText = "Silo • " + truncateWithEllipsis(row.SeriesTitle, discordFooterLimit-16) + } + if test { + footerText = "Silo test notification" + } + + fields := make([]discordEmbedField, 0, 3) + if labels := reasonLabelList(flags); len(labels) > 0 { + fields = append(fields, discordEmbedField{ + Name: "Reason", + Value: truncateWithEllipsis(strings.Join(labels, " & "), discordFieldValueLimit), + Inline: true, + }) + } + if row.SeasonNumber != nil { + fields = append(fields, discordEmbedField{ + Name: "Season", + Value: fmt.Sprintf("%d", *row.SeasonNumber), + Inline: true, + }) + } + if row.EpisodeNumber != nil { + fields = append(fields, discordEmbedField{ + Name: "Episode", + Value: fmt.Sprintf("%d", *row.EpisodeNumber), + Inline: true, + }) + } + + embed := discordEmbed{ + Title: title, + Description: description, + Color: discordEmbedColor(flags), + Footer: &discordEmbedFooter{Text: footerText}, + Fields: fields, + } + if !row.CreatedAt.IsZero() { + embed.Timestamp = row.CreatedAt.UTC().Format(time.RFC3339) + } + enforceDiscordTotalLimit(&embed) + + return json.Marshal(discordWebhookBody{ + Embeds: []discordEmbed{embed}, + Username: "Silo", + }) +} + +func discordEmbedTitle(row DeliveryRow) string { + if row.Type != DeliveryTypeEpisodeAvailable { + return genericNotificationTitle + } + series := row.SeriesTitle + if series == "" { + series = "New episode" + } + var code string + if row.SeasonNumber != nil && row.EpisodeNumber != nil { + code = fmt.Sprintf("S%d E%d", *row.SeasonNumber, *row.EpisodeNumber) + } + switch { + case code != "" && row.EpisodeTitle != "": + return fmt.Sprintf("%s — %s: %s", series, code, row.EpisodeTitle) + case code != "": + return fmt.Sprintf("%s — %s", series, code) + default: + return series + } +} + +func discordEmbedColor(flags ReasonFlags) int { + switch { + case flags.Favorite: + return discordColorFavorite + case flags.Watchlist: + return discordColorWatchlist + case flags.ContinueWatching: + return discordColorContinueWatching + case flags.NextUp: + return discordColorNextUp + default: + return discordColorFavorite + } +} + +func discordEmbedTotal(embed *discordEmbed) int { + total := len(embed.Title) + len(embed.Description) + if embed.Footer != nil { + total += len(embed.Footer.Text) + } + for _, field := range embed.Fields { + total += len(field.Name) + len(field.Value) + } + return total +} + +// enforceDiscordTotalLimit applies the 6,000-char total cap: truncate the +// description first, then drop fields right-to-left. +func enforceDiscordTotalLimit(embed *discordEmbed) { + embed.Description = truncateWithEllipsis(embed.Description, discordDescriptionLimit) + if discordEmbedTotal(embed) <= discordTotalLimit { + return + } + overflow := discordEmbedTotal(embed) - discordTotalLimit + if keep := len(embed.Description) - overflow; keep > 0 { + embed.Description = truncateWithEllipsis(embed.Description, keep) + } else { + embed.Description = "" + } + for discordEmbedTotal(embed) > discordTotalLimit && len(embed.Fields) > 0 { + embed.Fields = embed.Fields[:len(embed.Fields)-1] + } +} + +func truncateWithEllipsis(value string, limit int) string { + const ellipsis = "…" // 3 bytes in UTF-8; limits are byte counts + if limit <= 0 { + return "" + } + if len(value) <= limit { + return value + } + if limit <= len(ellipsis) { + return value[:0] + } + runes := []rune(value) + for len(runes) > 0 && len(string(runes))+len(ellipsis) > limit { + runes = runes[:len(runes)-1] + } + return string(runes) + ellipsis +} diff --git a/internal/notifications/webhook_payload_generic.go b/internal/notifications/webhook_payload_generic.go new file mode 100644 index 00000000..f3e08710 --- /dev/null +++ b/internal/notifications/webhook_payload_generic.go @@ -0,0 +1,101 @@ +package notifications + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "time" +) + +// genericWebhookBody is the canonical Silo webhook JSON +// (docs/superpowers/plans/notifications/04, "Generic"). The HMAC signature is +// computed over the literal bytes Silo sends; receivers verify against the +// literal bytes they received, so no canonicalization is required on either +// side. No server URL, no absolute artwork URLs, no library name. +type genericWebhookBody struct { + Event string `json:"event"` + DeliveryID string `json:"delivery_id"` + WebhookID string `json:"webhook_id"` + Timestamp string `json:"timestamp"` + Version int `json:"version"` + Test bool `json:"test"` + ProfileID string `json:"profile_id"` + LibraryID *int `json:"library_id,omitempty"` + Type string `json:"type"` + Reasons ReasonFlags `json:"reason_flags"` + Series *genericWebhookSeries `json:"series,omitempty"` + Episode *genericWebhookEpisode `json:"episode,omitempty"` +} + +type genericWebhookSeries struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type genericWebhookEpisode struct { + ID string `json:"id"` + Title string `json:"title"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` +} + +// BuildGenericWebhookPayload renders a delivery as canonical Silo JSON. Pure +// function. +func BuildGenericWebhookPayload(row DeliveryRow, webhookID string, test bool) ([]byte, error) { + createdAt := row.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + body := genericWebhookBody{ + Event: EventNotificationCreated, + DeliveryID: row.ID, + WebhookID: webhookID, + Timestamp: createdAt.UTC().Format(time.RFC3339), + Version: 1, + Test: test, + ProfileID: row.ProfileID, + LibraryID: row.LibraryID, + Type: row.Type, + Reasons: parseReasonFlags(row.ReasonFlags), + } + if row.SeriesID != nil { + body.Series = &genericWebhookSeries{ID: *row.SeriesID, Title: row.SeriesTitle} + } + if row.EpisodeID != nil { + body.Episode = &genericWebhookEpisode{ + ID: *row.EpisodeID, + Title: row.EpisodeTitle, + SeasonNumber: row.SeasonNumber, + EpisodeNumber: row.EpisodeNumber, + } + } + return json.Marshal(body) +} + +// SignGenericWebhook computes the X-Silo-Signature header value for a body: +// "t=,v1=."))>", following +// Stripe's signing convention so receivers can reuse existing verifiers. +func SignGenericWebhook(secret string, timestamp int64, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(strconv.AppendInt(nil, timestamp, 10)) + mac.Write([]byte{'.'}) + mac.Write(body) + return fmt.Sprintf("t=%d,v1=%s", timestamp, hex.EncodeToString(mac.Sum(nil))) +} + +// genericWebhookHeaders builds the delivery headers for a generic webhook +// POST. The timestamp participating in the HMAC is the Unix-epoch header +// value, not the body's RFC3339 timestamp. +func genericWebhookHeaders(webhookID, deliveryID, secret string, now time.Time, body []byte) map[string]string { + timestamp := now.Unix() + return map[string]string{ + "X-Silo-Event": EventNotificationCreated, + "X-Silo-Webhook-Id": webhookID, + "X-Silo-Delivery-Id": deliveryID, + "X-Silo-Timestamp": fmt.Sprintf("%d", timestamp), + "X-Silo-Signature": SignGenericWebhook(secret, timestamp, body), + } +} diff --git a/internal/notifications/webhook_repo.go b/internal/notifications/webhook_repo.go new file mode 100644 index 00000000..7374f008 --- /dev/null +++ b/internal/notifications/webhook_repo.go @@ -0,0 +1,364 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// WebhookRepository owns notification_webhooks and webhook_delivery_attempts. +type WebhookRepository struct { + pool *pgxpool.Pool +} + +// NewWebhookRepository creates a WebhookRepository. +func NewWebhookRepository(pool *pgxpool.Pool) *WebhookRepository { + return &WebhookRepository{pool: pool} +} + +const webhookColumns = ` + id, user_id, profile_id, name, type, url_ciphertext, url_host, + signing_secret_ciphertext, enabled, + notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up, + consecutive_failures, disabled_reason, + last_success_at, last_failure_at, last_failure_status, last_failure_message, + created_at, updated_at` + +func scanWebhook(row pgx.Row) (*Webhook, error) { + var hook Webhook + err := row.Scan( + &hook.ID, &hook.UserID, &hook.ProfileID, &hook.Name, &hook.Type, + &hook.URLCiphertext, &hook.URLHost, &hook.SigningSecretCiphertext, &hook.Enabled, + &hook.NotifyFavorites, &hook.NotifyWatchlist, &hook.NotifyContinueWatching, &hook.NotifyNextUp, + &hook.ConsecutiveFailures, &hook.DisabledReason, + &hook.LastSuccessAt, &hook.LastFailureAt, &hook.LastFailureStatus, &hook.LastFailureMessage, + &hook.CreatedAt, &hook.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &hook, nil +} + +func scanWebhooks(rows pgx.Rows) ([]Webhook, error) { + defer rows.Close() + hooks := make([]Webhook, 0, 4) + for rows.Next() { + hook, err := scanWebhook(rows) + if err != nil { + return nil, fmt.Errorf("scan webhook: %w", err) + } + hooks = append(hooks, *hook) + } + return hooks, rows.Err() +} + +// ListByProfile returns all of a profile's webhooks for the listing endpoint. +func (r *WebhookRepository) ListByProfile(ctx context.Context, profileID string) ([]Webhook, error) { + rows, err := r.pool.Query(ctx, + `SELECT `+webhookColumns+` FROM notification_webhooks WHERE profile_id = $1 ORDER BY created_at`, + profileID) + if err != nil { + return nil, fmt.Errorf("list webhooks: %w", err) + } + return scanWebhooks(rows) +} + +// ListEnabledByProfiles loads enabled webhooks for a set of profiles, keyed +// by profile. Used by the fanout outbox enqueue inside its transaction. +func (r *WebhookRepository) ListEnabledByProfiles(ctx context.Context, tx pgx.Tx, profileIDs []string) (map[string][]Webhook, error) { + out := make(map[string][]Webhook, len(profileIDs)) + if len(profileIDs) == 0 { + return out, nil + } + rows, err := tx.Query(ctx, + `SELECT `+webhookColumns+` FROM notification_webhooks WHERE profile_id = ANY($1) AND enabled`, + profileIDs) + if err != nil { + return nil, fmt.Errorf("list enabled webhooks: %w", err) + } + hooks, err := scanWebhooks(rows) + if err != nil { + return nil, err + } + for _, hook := range hooks { + out[hook.ProfileID] = append(out[hook.ProfileID], hook) + } + return out, nil +} + +// GetByID returns one webhook scoped to the profile; (nil, nil) when absent. +func (r *WebhookRepository) GetByID(ctx context.Context, profileID, id string) (*Webhook, error) { + hook, err := scanWebhook(r.pool.QueryRow(ctx, + `SELECT `+webhookColumns+` FROM notification_webhooks WHERE profile_id = $1 AND id = $2`, + profileID, id)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get webhook: %w", err) + } + return hook, nil +} + +// getByIDUnscoped loads a webhook for internal delivery paths. +func (r *WebhookRepository) getByIDUnscoped(ctx context.Context, id string) (*Webhook, error) { + hook, err := scanWebhook(r.pool.QueryRow(ctx, + `SELECT `+webhookColumns+` FROM notification_webhooks WHERE id = $1`, id)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get webhook: %w", err) + } + return hook, nil +} + +// CountByProfile returns the profile's webhook count (for the per-profile cap). +func (r *WebhookRepository) CountByProfile(ctx context.Context, profileID string) (int, error) { + var count int + err := r.pool.QueryRow(ctx, + `SELECT count(*) FROM notification_webhooks WHERE profile_id = $1`, profileID, + ).Scan(&count) + return count, err +} + +// ErrWebhookNameTaken is returned when a profile already has a webhook with +// the requested name. +var ErrWebhookNameTaken = errors.New("a webhook with this name already exists") + +// Insert persists a new webhook. +func (r *WebhookRepository) Insert(ctx context.Context, hook Webhook) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO notification_webhooks + (id, user_id, profile_id, name, type, url_ciphertext, url_host, + signing_secret_ciphertext, enabled, + notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + hook.ID, hook.UserID, hook.ProfileID, hook.Name, hook.Type, + hook.URLCiphertext, hook.URLHost, hook.SigningSecretCiphertext, hook.Enabled, + hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp) + if err != nil { + if strings.Contains(err.Error(), "notification_webhooks_profile_name_key") { + return ErrWebhookNameTaken + } + return fmt.Errorf("insert webhook: %w", err) + } + return nil +} + +// Update persists the mutable fields of a webhook (name, URL, flags, +// enabled state, secret) and bumps updated_at. +func (r *WebhookRepository) Update(ctx context.Context, hook Webhook) error { + _, err := r.pool.Exec(ctx, ` + UPDATE notification_webhooks SET + name = $2, type = $3, url_ciphertext = $4, url_host = $5, + signing_secret_ciphertext = $6, enabled = $7, + notify_favorites = $8, notify_watchlist = $9, + notify_continue_watching = $10, notify_next_up = $11, + consecutive_failures = $12, disabled_reason = $13, + updated_at = now() + WHERE id = $1`, + hook.ID, hook.Name, hook.Type, hook.URLCiphertext, hook.URLHost, + hook.SigningSecretCiphertext, hook.Enabled, + hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp, + hook.ConsecutiveFailures, hook.DisabledReason) + if err != nil { + if strings.Contains(err.Error(), "notification_webhooks_profile_name_key") { + return ErrWebhookNameTaken + } + return fmt.Errorf("update webhook: %w", err) + } + return nil +} + +// Delete removes a profile's webhook; attempts cascade. Idempotent. +func (r *WebhookRepository) Delete(ctx context.Context, profileID, id string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM notification_webhooks WHERE profile_id = $1 AND id = $2`, profileID, id) + return err +} + +// DeleteAllForProfile removes a deleted profile's webhooks. +func (r *WebhookRepository) DeleteAllForProfile(ctx context.Context, profileID string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM notification_webhooks WHERE profile_id = $1`, profileID) + return err +} + +// RecordSuccess resets the failure streak after a delivered attempt. +func (r *WebhookRepository) RecordSuccess(ctx context.Context, webhookID string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE notification_webhooks + SET last_success_at = now(), consecutive_failures = 0, updated_at = now() + WHERE id = $1`, webhookID) + return err +} + +// RecordFailure increments the failure streak and stores the diagnostic. +func (r *WebhookRepository) RecordFailure(ctx context.Context, webhookID string, httpStatus *int, message string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE notification_webhooks + SET last_failure_at = now(), + last_failure_status = $2, + last_failure_message = left($3, 256), + consecutive_failures = consecutive_failures + 1, + updated_at = now() + WHERE id = $1`, webhookID, httpStatus, message) + return err +} + +// Disable auto-disables a webhook with a profile-visible reason. +func (r *WebhookRepository) Disable(ctx context.Context, webhookID, reason string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE notification_webhooks + SET enabled = false, disabled_reason = left($2, 256), updated_at = now() + WHERE id = $1`, webhookID, reason) + return err +} + +// RecentFinalOutcomes returns the most recent terminal attempt outcomes +// (delivered/failed/auto_disabled) for a webhook, newest first. Used for the +// 3-consecutive-non-retryable-4xx auto-disable rule. +func (r *WebhookRepository) RecentFinalOutcomes(ctx context.Context, webhookID string, limit int) ([]DeliveryAttempt, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, notification_delivery_id, webhook_id, attempt_number, attempted_at, + next_retry_at, http_status, outcome, failure_message + FROM webhook_delivery_attempts + WHERE webhook_id = $1 AND outcome IN ('delivered', 'failed', 'auto_disabled') + ORDER BY attempted_at DESC + LIMIT $2`, webhookID, limit) + if err != nil { + return nil, fmt.Errorf("list final webhook outcomes: %w", err) + } + return scanDeliveryAttempts(rows) +} + +func scanDeliveryAttempts(rows pgx.Rows) ([]DeliveryAttempt, error) { + defer rows.Close() + attempts := make([]DeliveryAttempt, 0, 8) + for rows.Next() { + var attempt DeliveryAttempt + if err := rows.Scan( + &attempt.ID, &attempt.NotificationDeliveryID, &attempt.TargetID, + &attempt.AttemptNumber, &attempt.AttemptedAt, &attempt.NextRetryAt, + &attempt.HTTPStatus, &attempt.Outcome, &attempt.FailureMessage, + ); err != nil { + return nil, fmt.Errorf("scan webhook attempt: %w", err) + } + attempts = append(attempts, attempt) + } + return attempts, rows.Err() +} + +// EnqueueAttempts inserts `pending` outbox rows inside the fanout +// transaction. attempt_number starts at 0 (no send tried yet). +func (r *WebhookRepository) EnqueueAttempts(ctx context.Context, tx pgx.Tx, attempts []DeliveryAttempt) error { + if len(attempts) == 0 { + return nil + } + var sb strings.Builder + sb.WriteString(` + INSERT INTO webhook_delivery_attempts + (id, notification_delivery_id, webhook_id, attempt_number, outcome) + VALUES `) + args := make([]any, 0, len(attempts)*5) + for i, attempt := range attempts { + if i > 0 { + sb.WriteString(", ") + } + base := len(args) + sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d,$%d)", base+1, base+2, base+3, base+4, base+5)) + args = append(args, attempt.ID, attempt.NotificationDeliveryID, attempt.TargetID, 0, WebhookOutcomePending) + } + sb.WriteString(" ON CONFLICT DO NOTHING") + if _, err := tx.Exec(ctx, sb.String(), args...); err != nil { + return fmt.Errorf("enqueue webhook attempts: %w", err) + } + return nil +} + +// claimLease is how long a claimed attempt is invisible to other claimers. A +// crash mid-send surfaces the attempt to the retry worker after the lease. +const webhookClaimLease = 90 * time.Second + +// ClaimPendingForDelivery claims a delivery's pending attempts for immediate +// post-commit dispatch. Claiming flips the row to `retrying` with a short +// lease instead of holding row locks across the HTTP send. +func (r *WebhookRepository) ClaimPendingForDelivery(ctx context.Context, deliveryID string) ([]DeliveryAttempt, error) { + return r.claim(ctx, ` + UPDATE webhook_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2 + WHERE id IN ( + SELECT id FROM webhook_delivery_attempts + WHERE notification_delivery_id = $1 AND outcome = 'pending' + FOR UPDATE SKIP LOCKED + ) + RETURNING id, notification_delivery_id, webhook_id, attempt_number, attempted_at, + next_retry_at, http_status, outcome, failure_message`, + deliveryID, webhookClaimLease) +} + +// ClaimDue claims attempts whose retry is due, plus stale pending rows whose +// post-commit dispatch never ran (outbox recovery after a crash). +func (r *WebhookRepository) ClaimDue(ctx context.Context, limit int) ([]DeliveryAttempt, error) { + return r.claim(ctx, ` + UPDATE webhook_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2 + WHERE id IN ( + SELECT id FROM webhook_delivery_attempts + WHERE (outcome = 'retrying' AND next_retry_at <= now()) + OR (outcome = 'pending' AND attempted_at <= now() - interval '60 seconds') + ORDER BY next_retry_at NULLS FIRST + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, notification_delivery_id, webhook_id, attempt_number, attempted_at, + next_retry_at, http_status, outcome, failure_message`, + limit, webhookClaimLease) +} + +func (r *WebhookRepository) claim(ctx context.Context, query string, args ...any) ([]DeliveryAttempt, error) { + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("claim webhook attempts: %w", err) + } + return scanDeliveryAttempts(rows) +} + +// FinalizeAttempt records a send result: the new outcome, the attempt number +// just consumed, and the optional next retry time. +func (r *WebhookRepository) FinalizeAttempt(ctx context.Context, attemptID, outcome string, attemptNumber int, httpStatus *int, failureMessage string, nextRetryAt *time.Time) error { + var message *string + if failureMessage != "" { + message = &failureMessage + } + _, err := r.pool.Exec(ctx, ` + UPDATE webhook_delivery_attempts + SET outcome = $2, attempt_number = $3, attempted_at = now(), + http_status = $4, failure_message = left($5, 256), next_retry_at = $6 + WHERE id = $1`, + attemptID, outcome, attemptNumber, httpStatus, message, nextRetryAt) + if err != nil { + return fmt.Errorf("finalize webhook attempt: %w", err) + } + return nil +} + +// DeleteOldAttempts applies attempt retention: delivered rows past 7 days, +// failed/auto_disabled rows past 30 days. Pending/retrying rows are kept +// until they resolve. +func (r *WebhookRepository) DeleteOldAttempts(ctx context.Context, now time.Time) (int64, error) { + tag, err := r.pool.Exec(ctx, ` + DELETE FROM webhook_delivery_attempts + WHERE (outcome = 'delivered' AND attempted_at < $1) + OR (outcome IN ('failed', 'auto_disabled') AND attempted_at < $2)`, + now.AddDate(0, 0, -7), now.AddDate(0, 0, -30)) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/internal/notifications/webhook_sender.go b/internal/notifications/webhook_sender.go new file mode 100644 index 00000000..2bb1406b --- /dev/null +++ b/internal/notifications/webhook_sender.go @@ -0,0 +1,321 @@ +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/secret" + "github.com/oklog/ulid/v2" +) + +// webhookRetrySchedule holds the cumulative delay since the first attempt +// (docs/superpowers/plans/notifications/04, "Retry schedule"). Index N is the +// delay before attempt N+1; after the last attempt fails, the webhook is +// auto-disabled. +var webhookRetrySchedule = []time.Duration{ + 0, + 30 * time.Second, + 2 * time.Minute, + 10 * time.Minute, + 30 * time.Minute, + 2 * time.Hour, + 6 * time.Hour, + 12 * time.Hour, + 18 * time.Hour, + 24 * time.Hour, +} + +const webhookMaxAttempts = 10 + +// webhookRetryDelay returns how long to wait after a retryable failure of +// attempt N (1-based) before the next attempt, or ok=false when the schedule +// is exhausted. +func webhookRetryDelay(completedAttempt int) (time.Duration, bool) { + if completedAttempt < 1 || completedAttempt >= webhookMaxAttempts { + return 0, false + } + return webhookRetrySchedule[completedAttempt] - webhookRetrySchedule[completedAttempt-1], true +} + +// retryableHTTPStatus reports whether an HTTP failure status is worth +// retrying. Non-retryable 4xx responses are deterministic destination-side +// rejections; 408/425/429 are the transient exceptions. +func retryableHTTPStatus(status int) bool { + if status == 0 || status >= 500 { + return true + } + switch status { + case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests: + return true + default: + return status < 400 + } +} + +const autoDisableConsecutive4xx = 3 + +// webhookSender owns the actual delivery of claimed attempts. It is shared +// by the post-commit dispatcher and the retry worker so both paths apply the +// same retry, auto-disable, and notification rules. +type webhookSender struct { + webhooks *WebhookRepository + deliveries *DeliveryRepository + cipher *secret.Cipher + settings *Settings + client *http.Client + hub *evt.Hub + payload func(ctx context.Context, row DeliveryRow) DeliveryRowPayload + logger *slog.Logger +} + +func newWebhookSender( + webhooks *WebhookRepository, + deliveries *DeliveryRepository, + cipher *secret.Cipher, + settings *Settings, + hub *evt.Hub, +) *webhookSender { + sender := &webhookSender{ + webhooks: webhooks, + deliveries: deliveries, + cipher: cipher, + settings: settings, + hub: hub, + logger: slog.Default().With("component", "notifications.webhooks"), + } + sender.client = newWebhookHTTPClient(func() bool { + return settings.WebhooksAllowPrivateDestinations(context.Background()) + }) + return sender +} + +func webhookURLAAD(id string) string { return "notification_webhook:" + id + ":url" } +func webhookSecretAAD(id string) string { return "notification_webhook:" + id + ":signing_secret" } + +func (s *webhookSender) decryptURL(hook *Webhook) (string, error) { + return s.cipher.Decrypt(hook.URLCiphertext, webhookURLAAD(hook.ID)) +} + +func (s *webhookSender) decryptSecret(hook *Webhook) (string, error) { + if hook.SigningSecretCiphertext == nil { + return "", fmt.Errorf("webhook has no signing secret") + } + return s.cipher.Decrypt(*hook.SigningSecretCiphertext, webhookSecretAAD(hook.ID)) +} + +// buildPayload renders the type-specific request body and headers. +func (s *webhookSender) buildPayload(hook *Webhook, row DeliveryRow, test bool) (body []byte, headers map[string]string, err error) { + switch hook.Type { + case WebhookTypeDiscord: + body, err = BuildDiscordWebhookPayload(row, test) + return body, nil, err + case WebhookTypeGeneric: + body, err = BuildGenericWebhookPayload(row, hook.ID, test) + if err != nil { + return nil, nil, err + } + signingSecret, err := s.decryptSecret(hook) + if err != nil { + return nil, nil, err + } + return body, genericWebhookHeaders(hook.ID, row.ID, signingSecret, time.Now(), body), nil + default: + return nil, nil, fmt.Errorf("unknown webhook type %q", hook.Type) + } +} + +// send POSTs one payload to the webhook's destination. +func (s *webhookSender) send(ctx context.Context, hook *Webhook, row DeliveryRow, test bool) webhookSendResult { + url, err := s.decryptURL(hook) + if err != nil { + return webhookSendResult{Message: "webhook URL could not be decrypted"} + } + body, headers, err := s.buildPayload(hook, row, test) + if err != nil { + return webhookSendResult{Message: "payload build failed"} + } + return sendWebhook(ctx, s.client, url, body, headers) +} + +// processAttempt delivers one claimed attempt and records the outcome: +// delivered, retrying with backoff, failed (non-retryable 4xx or exhausted +// schedule), and the auto-disable transitions. +func (s *webhookSender) processAttempt(ctx context.Context, attempt DeliveryAttempt) { + hook, err := s.webhooks.getByIDUnscoped(ctx, attempt.TargetID) + if err != nil || hook == nil { + // Webhook deleted between enqueue and dispatch: the cascade removes + // attempts; nothing to do beyond closing this one out if it survived. + if err == nil { + _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "webhook deleted", nil) + } + return + } + if !hook.Enabled || !s.settings.WebhooksEnabled(ctx) { + _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "webhook disabled", nil) + return + } + + row, err := s.deliveries.GetRowByID(ctx, attempt.NotificationDeliveryID) + if err != nil || row == nil { + _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "delivery row missing", nil) + return + } + + result := s.send(ctx, hook, *row, false) + attemptNumber := attempt.AttemptNumber + 1 + + if result.OK { + _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeDelivered, + attemptNumber, &result.HTTPStatus, "", nil) + if err := s.webhooks.RecordSuccess(ctx, hook.ID); err != nil { + s.logger.Warn("webhook success bookkeeping failed", "webhook_id", hook.ID, "error", err) + } + return + } + + var status *int + if result.HTTPStatus > 0 { + status = &result.HTTPStatus + } + if err := s.webhooks.RecordFailure(ctx, hook.ID, status, result.Message); err != nil { + s.logger.Warn("webhook failure bookkeeping failed", "webhook_id", hook.ID, "error", err) + } + + if result.HTTPStatus > 0 && !retryableHTTPStatus(result.HTTPStatus) { + // Deterministic destination-side rejection: fail this delivery now. + _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attemptNumber, status, result.Message, nil) + s.maybeDisableAfter4xx(ctx, hook, result) + return + } + + delay, more := webhookRetryDelay(attemptNumber) + if result.RetryAfter > 0 { + delay = result.RetryAfter + } + if more { + nextRetry := time.Now().Add(delay) + _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeRetrying, + attemptNumber, status, result.Message, &nextRetry) + return + } + + // Retry schedule exhausted (~24h of consecutive failures). + _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeAutoDisabled, + attemptNumber, status, result.Message, nil) + s.disableWebhook(ctx, hook, result, "Deliveries failed for 24 hours") +} + +// maybeDisableAfter4xx applies the 3-consecutive-non-retryable-4xx rule. A +// single 4xx is not proof the webhook is dead (CDN/WAF blips), but three +// consecutive deterministic rejections are. +func (s *webhookSender) maybeDisableAfter4xx(ctx context.Context, hook *Webhook, result webhookSendResult) { + recent, err := s.webhooks.RecentFinalOutcomes(ctx, hook.ID, autoDisableConsecutive4xx) + if err != nil { + s.logger.Warn("webhook 4xx history lookup failed", "webhook_id", hook.ID, "error", err) + return + } + if len(recent) < autoDisableConsecutive4xx { + return + } + for _, attempt := range recent { + if attempt.Outcome != WebhookOutcomeFailed || + attempt.HTTPStatus == nil || retryableHTTPStatus(*attempt.HTTPStatus) { + return + } + } + s.disableWebhook(ctx, hook, result, + fmt.Sprintf("%d consecutive deliveries rejected by the destination", autoDisableConsecutive4xx)) +} + +// disableWebhook auto-disables the webhook and posts the in-app notice so the +// failure is not silent. The notice type is on the webhook deny list, so it +// can never loop back into another webhook attempt. +func (s *webhookSender) disableWebhook(ctx context.Context, hook *Webhook, result webhookSendResult, reason string) { + fullReason := reason + if result.Message != "" { + fullReason = fmt.Sprintf("%s (last error: %s)", reason, result.Message) + } + if err := s.webhooks.Disable(ctx, hook.ID, fullReason); err != nil { + s.logger.Error("webhook auto-disable failed", "webhook_id", hook.ID, "error", err) + return + } + s.logger.Warn("webhook auto-disabled", + "webhook_id", hook.ID, "url_host", hook.URLHost, "reason", fullReason) + + noticeFlags, err := json.Marshal(map[string]any{ + "webhook_id": hook.ID, + "webhook_name": hook.Name, + "last_failure_status": result.HTTPStatus, + }) + if err != nil { + return + } + notice := Delivery{ + ID: ulid.Make().String(), + UserID: hook.UserID, + ProfileID: hook.ProfileID, + Type: DeliveryTypeWebhookAutoDisabled, + ReasonFlags: noticeFlags, + } + inserted, err := s.deliveries.InsertOperational(ctx, notice) + if err != nil || inserted == nil { + s.logger.Warn("webhook auto-disable notice insert failed", "webhook_id", hook.ID, "error", err) + return + } + notice.ID = inserted.ID + notice.CreatedAt = inserted.CreatedAt + if s.hub != nil { + payload := PayloadForRow(DeliveryRow{Delivery: notice}) + if s.payload != nil { + payload = s.payload(ctx, DeliveryRow{Delivery: notice}) + } + _ = s.hub.PublishJSON(ctx, evt.ChannelNotifications, EventNotificationCreated, + payload, evt.PublishOptions{UserID: hook.UserID, ProfileID: hook.ProfileID}) + } +} + +// profileRateLimiter is a per-profile sliding-window counter bounding webhook +// deliveries per minute. Over-limit notifications stay in the inbox; webhook +// attempts simply are not enqueued. Per-node state: fanout claims are +// node-exclusive (SKIP LOCKED), so one node owns a given event's enqueue. +type profileRateLimiter struct { + mu sync.Mutex + windows map[string][]time.Time +} + +func newProfileRateLimiter() *profileRateLimiter { + return &profileRateLimiter{windows: make(map[string][]time.Time)} +} + +// Allow reports whether the profile is under the per-minute limit and counts +// the delivery when it is. +func (l *profileRateLimiter) Allow(profileID string, limit int) bool { + now := time.Now() + cutoff := now.Add(-time.Minute) + l.mu.Lock() + defer l.mu.Unlock() + + window := l.windows[profileID] + kept := window[:0] + for _, ts := range window { + if ts.After(cutoff) { + kept = append(kept, ts) + } + } + if len(kept) >= limit { + l.windows[profileID] = kept + return false + } + l.windows[profileID] = append(kept, now) + return true +} diff --git a/internal/notifications/webhook_service.go b/internal/notifications/webhook_service.go new file mode 100644 index 00000000..d1cd932d --- /dev/null +++ b/internal/notifications/webhook_service.go @@ -0,0 +1,319 @@ +package notifications + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/secret" + "github.com/oklog/ulid/v2" +) + +// Webhook service errors surfaced to the API layer. +var ( + ErrWebhookInvalid = errors.New("invalid webhook") + ErrWebhookNotFound = errors.New("webhook not found") + ErrWebhookLimit = errors.New("webhook limit reached") +) + +// WebhookService owns webhook CRUD, validation, and signing-secret handling. +// URLs and secrets are encrypted at rest, bound to the webhook row identity, +// and never returned after creation (the URL token IS the credential for +// Discord webhooks). +type WebhookService struct { + repo *WebhookRepository + cipher *secret.Cipher + settings *Settings + sender *webhookSender +} + +func newWebhookService(repo *WebhookRepository, cipher *secret.Cipher, settings *Settings, sender *webhookSender) *WebhookService { + return &WebhookService{repo: repo, cipher: cipher, settings: settings, sender: sender} +} + +// WebhookInput is the create/update request shape. Pointer fields are +// optional on update; Create requires Name and URL. +type WebhookInput struct { + Name *string + URL *string + Type *string + Enabled *bool + NotifyFavorites *bool + NotifyWatchlist *bool + NotifyContinueWatching *bool + NotifyNextUp *bool +} + +func validateWebhookName(name string) (string, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "", fmt.Errorf("%w: name is required", ErrWebhookInvalid) + } + if len(trimmed) > 64 { + return "", fmt.Errorf("%w: name must be 64 characters or fewer", ErrWebhookInvalid) + } + return trimmed, nil +} + +func newSigningSecret() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate signing secret: %w", err) + } + return base64.StdEncoding.EncodeToString(raw), nil +} + +// List returns the profile's webhooks (ciphertext fields are for internal +// use; the handler view must expose url_host only). +func (s *WebhookService) List(ctx context.Context, profileID string) ([]Webhook, error) { + return s.repo.ListByProfile(ctx, profileID) +} + +// Get returns one webhook scoped to the profile. +func (s *WebhookService) Get(ctx context.Context, profileID, id string) (*Webhook, error) { + return s.repo.GetByID(ctx, profileID, id) +} + +// Create validates and persists a new webhook. For generic webhooks the +// returned signingSecret is shown exactly once. +func (s *WebhookService) Create(ctx context.Context, userID int, profileID string, input WebhookInput) (*Webhook, string, error) { + if input.Name == nil || input.URL == nil { + return nil, "", fmt.Errorf("%w: name and url are required", ErrWebhookInvalid) + } + name, err := validateWebhookName(*input.Name) + if err != nil { + return nil, "", err + } + + count, err := s.repo.CountByProfile(ctx, profileID) + if err != nil { + return nil, "", err + } + if count >= s.settings.WebhooksMaxPerProfile(ctx) { + return nil, "", ErrWebhookLimit + } + + rawURL := strings.TrimSpace(*input.URL) + host, err := ValidateWebhookURL(rawURL, s.settings.WebhooksAllowPrivateDestinations(ctx)) + if err != nil { + return nil, "", fmt.Errorf("%w: %s", ErrWebhookInvalid, err.Error()) + } + + hookType := "" + if input.Type != nil { + hookType = strings.TrimSpace(*input.Type) + } + switch hookType { + case "": + hookType = WebhookTypeGeneric + if discordWebhookURL(rawURL) { + hookType = WebhookTypeDiscord + } + case WebhookTypeDiscord, WebhookTypeGeneric: + default: + return nil, "", fmt.Errorf("%w: type must be discord or generic", ErrWebhookInvalid) + } + + hook := Webhook{ + ID: ulid.Make().String(), + UserID: userID, + ProfileID: profileID, + Name: name, + Type: hookType, + URLHost: host, + Enabled: true, + NotifyFavorites: boolOrDefault(input.NotifyFavorites, true), + NotifyWatchlist: boolOrDefault(input.NotifyWatchlist, true), + NotifyContinueWatching: boolOrDefault(input.NotifyContinueWatching, true), + NotifyNextUp: boolOrDefault(input.NotifyNextUp, true), + } + hook.URLCiphertext, err = s.cipher.Encrypt(rawURL, webhookURLAAD(hook.ID)) + if err != nil { + return nil, "", fmt.Errorf("encrypt webhook url: %w", err) + } + + signingSecret := "" + if hookType == WebhookTypeGeneric { + signingSecret, err = newSigningSecret() + if err != nil { + return nil, "", err + } + ciphertext, err := s.cipher.Encrypt(signingSecret, webhookSecretAAD(hook.ID)) + if err != nil { + return nil, "", fmt.Errorf("encrypt signing secret: %w", err) + } + hook.SigningSecretCiphertext = &ciphertext + } + + if err := s.repo.Insert(ctx, hook); err != nil { + if errors.Is(err, ErrWebhookNameTaken) { + return nil, "", fmt.Errorf("%w: %s", ErrWebhookInvalid, err.Error()) + } + return nil, "", err + } + return &hook, signingSecret, nil +} + +// Update applies the provided fields. A URL change re-validates the +// destination and resets the failure streak; re-enabling clears the +// auto-disable reason. +func (s *WebhookService) Update(ctx context.Context, profileID, id string, input WebhookInput) (*Webhook, error) { + hook, err := s.repo.GetByID(ctx, profileID, id) + if err != nil { + return nil, err + } + if hook == nil { + return nil, ErrWebhookNotFound + } + + if input.Name != nil { + name, err := validateWebhookName(*input.Name) + if err != nil { + return nil, err + } + hook.Name = name + } + if input.URL != nil { + rawURL := strings.TrimSpace(*input.URL) + host, err := ValidateWebhookURL(rawURL, s.settings.WebhooksAllowPrivateDestinations(ctx)) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrWebhookInvalid, err.Error()) + } + // The type is fixed at creation; a replacement URL must stay + // compatible so existing receivers keep working. + if hook.Type == WebhookTypeDiscord && !discordWebhookURL(rawURL) { + return nil, fmt.Errorf("%w: a Discord webhook needs a Discord webhook URL", ErrWebhookInvalid) + } + hook.URLCiphertext, err = s.cipher.Encrypt(rawURL, webhookURLAAD(hook.ID)) + if err != nil { + return nil, fmt.Errorf("encrypt webhook url: %w", err) + } + hook.URLHost = host + hook.ConsecutiveFailures = 0 + hook.DisabledReason = nil + } + if input.Enabled != nil { + hook.Enabled = *input.Enabled + if hook.Enabled { + hook.DisabledReason = nil + hook.ConsecutiveFailures = 0 + } + } + if input.NotifyFavorites != nil { + hook.NotifyFavorites = *input.NotifyFavorites + } + if input.NotifyWatchlist != nil { + hook.NotifyWatchlist = *input.NotifyWatchlist + } + if input.NotifyContinueWatching != nil { + hook.NotifyContinueWatching = *input.NotifyContinueWatching + } + if input.NotifyNextUp != nil { + hook.NotifyNextUp = *input.NotifyNextUp + } + + if err := s.repo.Update(ctx, *hook); err != nil { + if errors.Is(err, ErrWebhookNameTaken) { + return nil, fmt.Errorf("%w: %s", ErrWebhookInvalid, err.Error()) + } + return nil, err + } + return hook, nil +} + +// Delete removes a webhook. Idempotent. +func (s *WebhookService) Delete(ctx context.Context, profileID, id string) error { + return s.repo.Delete(ctx, profileID, id) +} + +// RotateSecret generates and stores a new signing secret for a generic +// webhook, returning it exactly once. The previous secret is gone +// immediately; there is no dual-acceptance window. +func (s *WebhookService) RotateSecret(ctx context.Context, profileID, id string) (string, error) { + hook, err := s.repo.GetByID(ctx, profileID, id) + if err != nil { + return "", err + } + if hook == nil { + return "", ErrWebhookNotFound + } + if hook.Type != WebhookTypeGeneric { + return "", fmt.Errorf("%w: only generic webhooks have signing secrets", ErrWebhookInvalid) + } + signingSecret, err := newSigningSecret() + if err != nil { + return "", err + } + ciphertext, err := s.cipher.Encrypt(signingSecret, webhookSecretAAD(hook.ID)) + if err != nil { + return "", fmt.Errorf("encrypt signing secret: %w", err) + } + hook.SigningSecretCiphertext = &ciphertext + if err := s.repo.Update(ctx, *hook); err != nil { + return "", err + } + return signingSecret, nil +} + +// WebhookTestResult is the synchronous outcome of a test send. +type WebhookTestResult struct { + OK bool `json:"ok"` + HTTPStatus int `json:"http_status,omitempty"` + DurationMS int64 `json:"duration_ms"` + Message string `json:"message,omitempty"` +} + +// Test synchronously POSTs a clearly marked sample payload. Test sends never +// touch webhook_delivery_attempts or the failure counters. +func (s *WebhookService) Test(ctx context.Context, profileID, id string) (*WebhookTestResult, error) { + hook, err := s.repo.GetByID(ctx, profileID, id) + if err != nil { + return nil, err + } + if hook == nil { + return nil, ErrWebhookNotFound + } + result := s.sender.send(ctx, hook, sampleDeliveryRow(profileID), true) + return &WebhookTestResult{ + OK: result.OK, + HTTPStatus: result.HTTPStatus, + DurationMS: result.Duration.Milliseconds(), + Message: result.Message, + }, nil +} + +// sampleDeliveryRow is the fixture used for test sends. +func sampleDeliveryRow(profileID string) DeliveryRow { + libraryID := 1 + seriesID := "test-series" + episodeID := "test-episode" + seasonNumber := 1 + episodeNumber := 1 + return DeliveryRow{ + Delivery: Delivery{ + ID: ulid.Make().String(), + ProfileID: profileID, + LibraryID: &libraryID, + SeriesID: &seriesID, + EpisodeID: &episodeID, + Type: DeliveryTypeEpisodeAvailable, + ReasonFlags: []byte(`{"favorite":true,"watchlist":false,"continue_watching":false,"next_up":false}`), + CreatedAt: time.Now(), + }, + SeriesTitle: "Silo Test Series", + EpisodeTitle: "This is a test notification", + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + } +} + +func boolOrDefault(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} diff --git a/internal/notifications/webhook_types.go b/internal/notifications/webhook_types.go new file mode 100644 index 00000000..b94517bb --- /dev/null +++ b/internal/notifications/webhook_types.go @@ -0,0 +1,113 @@ +package notifications + +import ( + "encoding/json" + "time" +) + +// Webhook types. +const ( + WebhookTypeDiscord = "discord" + WebhookTypeGeneric = "generic" +) + +// Webhook attempt outcomes. +const ( + WebhookOutcomePending = "pending" + WebhookOutcomeDelivered = "delivered" + WebhookOutcomeRetrying = "retrying" + WebhookOutcomeFailed = "failed" + WebhookOutcomeAutoDisabled = "auto_disabled" +) + +// DeliveryTypeWebhookAutoDisabled is the operational in-app notice posted +// when a webhook is auto-disabled. Its reason_flags carry +// {"webhook_id","webhook_name","last_failure_status"} instead of reason +// booleans, and it must never itself enqueue webhook attempts (loop guard). +const DeliveryTypeWebhookAutoDisabled = "webhook.auto_disabled" + +// genericNotificationTitle is the display fallback for delivery types this +// build does not know (the type registry is extensible). +const genericNotificationTitle = "Silo notification" + +// Webhook is a profile-scoped outbound destination. URL and signing secret +// are stored as enc:v1: envelopes and never leave the server. +type Webhook struct { + ID string + UserID int + ProfileID string + Name string + Type string + URLCiphertext string + URLHost string + SigningSecretCiphertext *string + Enabled bool + NotifyFavorites bool + NotifyWatchlist bool + NotifyContinueWatching bool + NotifyNextUp bool + ConsecutiveFailures int + DisabledReason *string + LastSuccessAt *time.Time + LastFailureAt *time.Time + LastFailureStatus *int + LastFailureMessage *string + CreatedAt time.Time + UpdatedAt time.Time +} + +// MatchesReasons reports whether at least one of the delivery's matched +// reasons passes this webhook's per-reason filter. Per-webhook flags narrow +// what fires for a destination; they can never re-enable a reason the profile +// disabled globally (those deliveries are never created). +func (w Webhook) MatchesReasons(flags ReasonFlags) bool { + return (flags.Favorite && w.NotifyFavorites) || + (flags.Watchlist && w.NotifyWatchlist) || + (flags.ContinueWatching && w.NotifyContinueWatching) || + (flags.NextUp && w.NotifyNextUp) +} + +// DeliveryAttempt is one row of a per-target dispatch outbox / retry log. +// TargetID is the channel-specific destination: a notification_webhooks.id +// for the webhook channel, a web_push_subscriptions.id for web push. +type DeliveryAttempt struct { + ID string + NotificationDeliveryID string + TargetID string + AttemptNumber int + AttemptedAt time.Time + NextRetryAt *time.Time + HTTPStatus *int + Outcome string + FailureMessage *string +} + +// parseReasonFlags decodes a delivery's reason_flags JSONB for +// episode.available rows. Operational types carry different shapes and decode +// to the zero value. +func parseReasonFlags(raw []byte) ReasonFlags { + var flags ReasonFlags + if len(raw) > 0 { + _ = json.Unmarshal(raw, &flags) + } + return flags +} + +// reasonLabelList renders matched reasons as display labels, in stable +// precedence order. +func reasonLabelList(flags ReasonFlags) []string { + labels := make([]string, 0, 4) + if flags.Favorite { + labels = append(labels, "Favorited") + } + if flags.Watchlist { + labels = append(labels, "Watchlisted") + } + if flags.ContinueWatching { + labels = append(labels, "Continue Watching") + } + if flags.NextUp { + labels = append(labels, "Next Up") + } + return labels +} diff --git a/internal/notifications/webpush_logic_test.go b/internal/notifications/webpush_logic_test.go new file mode 100644 index 00000000..3d821ef9 --- /dev/null +++ b/internal/notifications/webpush_logic_test.go @@ -0,0 +1,110 @@ +package notifications + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" +) + +func TestBuildWebPushPayload(t *testing.T) { + t.Run("episode available", func(t *testing.T) { + raw, err := buildWebPushPayload(webhookTestRow(), "https://cdn.example.com/poster.jpg") + if err != nil { + t.Fatalf("build failed: %v", err) + } + var payload webPushPayload + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + if payload.Title != "New episode of Severance" { + t.Fatalf("unexpected title %q", payload.Title) + } + if payload.Body != "S2E1 — Hello, Ms. Cobel" { + t.Fatalf("unexpected body %q", payload.Body) + } + if payload.URL != "/item/episode-456" { + t.Fatalf("unexpected url %q", payload.URL) + } + if payload.Icon != "https://cdn.example.com/poster.jpg" || payload.DeliveryID != "01DELIVERY" { + t.Fatalf("unexpected payload: %+v", payload) + } + }) + + t.Run("webhook auto-disable routes to settings", func(t *testing.T) { + row := DeliveryRow{Delivery: Delivery{ID: "01X", Type: DeliveryTypeWebhookAutoDisabled}} + raw, err := buildWebPushPayload(row, "") + if err != nil { + t.Fatal(err) + } + var payload webPushPayload + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + if payload.URL != "/settings/notifications" { + t.Fatalf("unexpected url %q", payload.URL) + } + }) + + t.Run("unknown types render generically", func(t *testing.T) { + row := DeliveryRow{Delivery: Delivery{ID: "01Y", Type: "future.type"}} + raw, err := buildWebPushPayload(row, "") + if err != nil { + t.Fatal(err) + } + var payload webPushPayload + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + if payload.Title != "Silo notification" || payload.URL != "/notifications" { + t.Fatalf("unexpected payload: %+v", payload) + } + }) +} + +func TestWebPushRetrySchedule(t *testing.T) { + total := time.Duration(0) + for attempt := 1; attempt < webPushMaxAttempts; attempt++ { + delay, ok := webPushRetryDelay(attempt) + if !ok { + t.Fatalf("schedule ended early at attempt %d", attempt) + } + total += delay + } + if total != 30*time.Minute { + t.Fatalf("schedule must span 30m, got %v", total) + } + if _, ok := webPushRetryDelay(webPushMaxAttempts); ok { + t.Fatal("attempt 5 must exhaust the schedule") + } +} + +func TestWebPushSubscribeValidation(t *testing.T) { + // Validation failures reject before any repository access, so a nil repo + // is safe here. + service := newWebPushService(nil, NewSettings(nil), nil) + ctx := context.Background() + + cases := []struct { + name string + endpoint string + p256dh string + auth string + }{ + {"missing endpoint", "", "k", "a"}, + {"missing keys", "https://push.example.com/x", "", ""}, + {"plain http", "http://push.example.com/x", "k", "a"}, + {"loopback", "https://127.0.0.1/x", "k", "a"}, + {"v4-mapped loopback", "https://[::ffff:127.0.0.1]/x", "k", "a"}, + {"private network", "https://192.168.1.10/x", "k", "a"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := service.Subscribe(ctx, 1, "p1", tc.endpoint, tc.p256dh, tc.auth, "test") + if !errors.Is(err, ErrWebPushInvalid) { + t.Fatalf("Subscribe = %v, want ErrWebPushInvalid", err) + } + }) + } +} diff --git a/internal/notifications/webpush_repo.go b/internal/notifications/webpush_repo.go new file mode 100644 index 00000000..ed050412 --- /dev/null +++ b/internal/notifications/webpush_repo.go @@ -0,0 +1,296 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// WebPushSubscription is one browser push registration, scoped to a profile. +type WebPushSubscription struct { + ID string + UserID int + ProfileID string + Endpoint string + P256dh string + Auth string + DeviceName string + Enabled bool + ConsecutiveFailures int + LastSuccessAt *time.Time + LastFailureAt *time.Time + LastFailureStatus *int + CreatedAt time.Time + UpdatedAt time.Time +} + +// WebPushRepository owns web_push_subscriptions and +// web_push_delivery_attempts. +type WebPushRepository struct { + pool *pgxpool.Pool +} + +// NewWebPushRepository creates a WebPushRepository. +func NewWebPushRepository(pool *pgxpool.Pool) *WebPushRepository { + return &WebPushRepository{pool: pool} +} + +const webPushColumns = ` + id, user_id, profile_id, endpoint, p256dh, auth, device_name, enabled, + consecutive_failures, last_success_at, last_failure_at, last_failure_status, + created_at, updated_at` + +func scanWebPushSubscription(row pgx.Row) (*WebPushSubscription, error) { + var sub WebPushSubscription + err := row.Scan( + &sub.ID, &sub.UserID, &sub.ProfileID, &sub.Endpoint, &sub.P256dh, &sub.Auth, + &sub.DeviceName, &sub.Enabled, &sub.ConsecutiveFailures, + &sub.LastSuccessAt, &sub.LastFailureAt, &sub.LastFailureStatus, + &sub.CreatedAt, &sub.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &sub, nil +} + +func scanWebPushSubscriptions(rows pgx.Rows) ([]WebPushSubscription, error) { + defer rows.Close() + subs := make([]WebPushSubscription, 0, 4) + for rows.Next() { + sub, err := scanWebPushSubscription(rows) + if err != nil { + return nil, fmt.Errorf("scan web push subscription: %w", err) + } + subs = append(subs, *sub) + } + return subs, rows.Err() +} + +// Upsert registers a subscription. An existing endpoint is reassigned to the +// caller's (user, profile): one browser endpoint notifies exactly one +// profile, the one that subscribed most recently. +func (r *WebPushRepository) Upsert(ctx context.Context, sub WebPushSubscription) (*WebPushSubscription, error) { + row := r.pool.QueryRow(ctx, ` + INSERT INTO web_push_subscriptions + (id, user_id, profile_id, endpoint, p256dh, auth, device_name) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (endpoint) DO UPDATE SET + user_id = EXCLUDED.user_id, + profile_id = EXCLUDED.profile_id, + p256dh = EXCLUDED.p256dh, + auth = EXCLUDED.auth, + device_name = EXCLUDED.device_name, + enabled = true, + consecutive_failures = 0, + updated_at = now() + RETURNING `+webPushColumns, + sub.ID, sub.UserID, sub.ProfileID, sub.Endpoint, sub.P256dh, sub.Auth, sub.DeviceName) + saved, err := scanWebPushSubscription(row) + if err != nil { + return nil, fmt.Errorf("upsert web push subscription: %w", err) + } + return saved, nil +} + +// ListByProfile returns a profile's subscriptions for the settings UI. +func (r *WebPushRepository) ListByProfile(ctx context.Context, profileID string) ([]WebPushSubscription, error) { + rows, err := r.pool.Query(ctx, + `SELECT `+webPushColumns+` FROM web_push_subscriptions WHERE profile_id = $1 ORDER BY created_at`, + profileID) + if err != nil { + return nil, fmt.Errorf("list web push subscriptions: %w", err) + } + return scanWebPushSubscriptions(rows) +} + +// ListEnabledByProfiles loads enabled subscriptions keyed by profile, inside +// the fanout transaction (outbox enqueue). +func (r *WebPushRepository) ListEnabledByProfiles(ctx context.Context, tx pgx.Tx, profileIDs []string) (map[string][]WebPushSubscription, error) { + out := make(map[string][]WebPushSubscription, len(profileIDs)) + if len(profileIDs) == 0 { + return out, nil + } + rows, err := tx.Query(ctx, + `SELECT `+webPushColumns+` FROM web_push_subscriptions WHERE profile_id = ANY($1) AND enabled`, + profileIDs) + if err != nil { + return nil, fmt.Errorf("list enabled web push subscriptions: %w", err) + } + subs, err := scanWebPushSubscriptions(rows) + if err != nil { + return nil, err + } + for _, sub := range subs { + out[sub.ProfileID] = append(out[sub.ProfileID], sub) + } + return out, nil +} + +func (r *WebPushRepository) getByIDUnscoped(ctx context.Context, id string) (*WebPushSubscription, error) { + sub, err := scanWebPushSubscription(r.pool.QueryRow(ctx, + `SELECT `+webPushColumns+` FROM web_push_subscriptions WHERE id = $1`, id)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get web push subscription: %w", err) + } + return sub, nil +} + +// Delete removes one subscription scoped to the profile. Idempotent. +func (r *WebPushRepository) Delete(ctx context.Context, profileID, id string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM web_push_subscriptions WHERE profile_id = $1 AND id = $2`, profileID, id) + return err +} + +// DeleteByEndpoint removes a subscription by its endpoint (browser +// unsubscribe flow, where the client only knows the endpoint). Scoped to the +// user, not the profile: Upsert reassigns an endpoint across profiles of the +// same account, so a disable issued under one profile must still delete the +// row even when another profile owns it. +func (r *WebPushRepository) DeleteByEndpoint(ctx context.Context, userID int, endpoint string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM web_push_subscriptions WHERE user_id = $1 AND endpoint = $2`, userID, endpoint) + return err +} + +// deleteGone removes a subscription the push service reports as expired or +// revoked (HTTP 404/410). The attempts cascade. +func (r *WebPushRepository) deleteGone(ctx context.Context, id string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM web_push_subscriptions WHERE id = $1`, id) + return err +} + +// DeleteAllForProfile removes a deleted profile's subscriptions. +func (r *WebPushRepository) DeleteAllForProfile(ctx context.Context, profileID string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM web_push_subscriptions WHERE profile_id = $1`, profileID) + return err +} + +// RecordSuccess resets the failure streak after a delivered push. +func (r *WebPushRepository) RecordSuccess(ctx context.Context, id string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE web_push_subscriptions + SET last_success_at = now(), consecutive_failures = 0, updated_at = now() + WHERE id = $1`, id) + return err +} + +// RecordFailure increments the failure streak. +func (r *WebPushRepository) RecordFailure(ctx context.Context, id string, httpStatus *int) error { + _, err := r.pool.Exec(ctx, ` + UPDATE web_push_subscriptions + SET last_failure_at = now(), last_failure_status = $2, + consecutive_failures = consecutive_failures + 1, updated_at = now() + WHERE id = $1`, id, httpStatus) + return err +} + +// --- Attempt outbox (mirrors webhook_delivery_attempts semantics) --- + +// EnqueueAttempts inserts `pending` outbox rows inside the fanout transaction. +func (r *WebPushRepository) EnqueueAttempts(ctx context.Context, tx pgx.Tx, attempts []DeliveryAttempt) error { + if len(attempts) == 0 { + return nil + } + var sb strings.Builder + sb.WriteString(` + INSERT INTO web_push_delivery_attempts + (id, notification_delivery_id, subscription_id, attempt_number, outcome) + VALUES `) + args := make([]any, 0, len(attempts)*5) + for i, attempt := range attempts { + if i > 0 { + sb.WriteString(", ") + } + base := len(args) + sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d,$%d)", base+1, base+2, base+3, base+4, base+5)) + args = append(args, attempt.ID, attempt.NotificationDeliveryID, attempt.TargetID, 0, WebhookOutcomePending) + } + sb.WriteString(" ON CONFLICT DO NOTHING") + if _, err := tx.Exec(ctx, sb.String(), args...); err != nil { + return fmt.Errorf("enqueue web push attempts: %w", err) + } + return nil +} + +const webPushAttemptReturning = ` + RETURNING id, notification_delivery_id, subscription_id, attempt_number, attempted_at, + next_retry_at, http_status, outcome, failure_message` + +// ClaimPendingForDelivery claims a delivery's pending attempts for immediate +// post-commit dispatch (lease-based, like webhooks). +func (r *WebPushRepository) ClaimPendingForDelivery(ctx context.Context, deliveryID string) ([]DeliveryAttempt, error) { + return r.claim(ctx, ` + UPDATE web_push_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2 + WHERE id IN ( + SELECT id FROM web_push_delivery_attempts + WHERE notification_delivery_id = $1 AND outcome = 'pending' + FOR UPDATE SKIP LOCKED + )`+webPushAttemptReturning, + deliveryID, webhookClaimLease) +} + +// ClaimDue claims due retries plus stale pending rows (outbox recovery). +func (r *WebPushRepository) ClaimDue(ctx context.Context, limit int) ([]DeliveryAttempt, error) { + return r.claim(ctx, ` + UPDATE web_push_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2 + WHERE id IN ( + SELECT id FROM web_push_delivery_attempts + WHERE (outcome = 'retrying' AND next_retry_at <= now()) + OR (outcome = 'pending' AND attempted_at <= now() - interval '60 seconds') + ORDER BY next_retry_at NULLS FIRST + LIMIT $1 + FOR UPDATE SKIP LOCKED + )`+webPushAttemptReturning, + limit, webhookClaimLease) +} + +func (r *WebPushRepository) claim(ctx context.Context, query string, args ...any) ([]DeliveryAttempt, error) { + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("claim web push attempts: %w", err) + } + return scanDeliveryAttempts(rows) +} + +// FinalizeAttempt records a send result. +func (r *WebPushRepository) FinalizeAttempt(ctx context.Context, attemptID, outcome string, attemptNumber int, httpStatus *int, failureMessage string, nextRetryAt *time.Time) error { + var message *string + if failureMessage != "" { + message = &failureMessage + } + _, err := r.pool.Exec(ctx, ` + UPDATE web_push_delivery_attempts + SET outcome = $2, attempt_number = $3, attempted_at = now(), + http_status = $4, failure_message = left($5, 256), next_retry_at = $6 + WHERE id = $1`, + attemptID, outcome, attemptNumber, httpStatus, message, nextRetryAt) + if err != nil { + return fmt.Errorf("finalize web push attempt: %w", err) + } + return nil +} + +// DeleteOldAttempts applies retention: delivered past 7 days, failed past 30. +func (r *WebPushRepository) DeleteOldAttempts(ctx context.Context, now time.Time) (int64, error) { + tag, err := r.pool.Exec(ctx, ` + DELETE FROM web_push_delivery_attempts + WHERE (outcome = 'delivered' AND attempted_at < $1) + OR (outcome = 'failed' AND attempted_at < $2)`, + now.AddDate(0, 0, -7), now.AddDate(0, 0, -30)) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/internal/notifications/webpush_sender.go b/internal/notifications/webpush_sender.go new file mode 100644 index 00000000..7c0fdb5c --- /dev/null +++ b/internal/notifications/webpush_sender.go @@ -0,0 +1,341 @@ +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "sync" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" +) + +// webPushRetrySchedule is deliberately shorter than the webhook schedule: +// vendor push services queue undeliverable messages themselves (the TTL +// covers offline devices), so server-side retries only need to ride out +// transient push-service errors. +var webPushRetrySchedule = []time.Duration{ + 0, + 30 * time.Second, + 2 * time.Minute, + 10 * time.Minute, + 30 * time.Minute, +} + +const ( + webPushMaxAttempts = 5 + webPushTTLSeconds = 12 * 60 * 60 // push-service queue TTL for offline devices +) + +func webPushRetryDelay(completedAttempt int) (time.Duration, bool) { + if completedAttempt < 1 || completedAttempt >= webPushMaxAttempts { + return 0, false + } + return webPushRetrySchedule[completedAttempt] - webPushRetrySchedule[completedAttempt-1], true +} + +// webPushPayload is the JSON the service worker receives. It is encrypted +// end-to-end (RFC 8291): only the subscribed browser can read it, never the +// vendor push service, so full display content is safe to include. +type webPushPayload struct { + Title string `json:"title"` + Body string `json:"body,omitempty"` + URL string `json:"url"` + Icon string `json:"icon,omitempty"` + Tag string `json:"tag,omitempty"` + DeliveryID string `json:"delivery_id"` +} + +// buildWebPushPayload renders a delivery for the service worker. +func buildWebPushPayload(row DeliveryRow, posterURL string) ([]byte, error) { + payload := webPushPayload{ + Title: "Silo", + URL: "/notifications", + Tag: row.ID, + DeliveryID: row.ID, + } + switch row.Type { + case DeliveryTypeEpisodeAvailable: + if row.SeriesTitle != "" { + payload.Title = "New episode of " + row.SeriesTitle + } else { + payload.Title = "New episode available" + } + var code string + if row.SeasonNumber != nil && row.EpisodeNumber != nil { + code = fmt.Sprintf("S%dE%d", *row.SeasonNumber, *row.EpisodeNumber) + } + switch { + case code != "" && row.EpisodeTitle != "": + payload.Body = code + " — " + row.EpisodeTitle + case code != "": + payload.Body = code + default: + payload.Body = row.EpisodeTitle + } + if row.EpisodeID != nil { + payload.URL = "/item/" + *row.EpisodeID + } + payload.Icon = posterURL + case DeliveryTypeWebhookAutoDisabled: + payload.Title = "A webhook stopped working" + payload.Body = "Open notification settings to fix it." + payload.URL = "/settings/notifications" + default: + // Unknown types render generically; the inbox has the details. + payload.Title = genericNotificationTitle + } + return json.Marshal(payload) +} + +// webPushSender delivers claimed web push attempts. Shared by the +// post-commit dispatcher and the retry worker. +type webPushSender struct { + subscriptions *WebPushRepository + deliveries *DeliveryRepository + service *WebPushService + settings *Settings + client *http.Client + payload func(ctx context.Context, row DeliveryRow) DeliveryRowPayload + logger *slog.Logger +} + +func newWebPushSender( + subscriptions *WebPushRepository, + deliveries *DeliveryRepository, + service *WebPushService, + settings *Settings, +) *webPushSender { + return &webPushSender{ + subscriptions: subscriptions, + deliveries: deliveries, + service: service, + settings: settings, + // Subscription endpoints are client-supplied URLs the server POSTs + // to: the SSRF-guarded client applies (vendor push services are + // public hosts, so legitimate endpoints always pass). + client: newWebhookHTTPClient(nil), + logger: slog.Default().With("component", "notifications.webpush"), + } +} + +// processAttempt sends one claimed attempt and records the outcome. Expired +// or revoked subscriptions (404/410 from the push service) are deleted — +// that is the protocol's unsubscribe signal, not a failure to retry. +func (s *webPushSender) processAttempt(ctx context.Context, attempt DeliveryAttempt) { + sub, err := s.subscriptions.getByIDUnscoped(ctx, attempt.TargetID) + if err != nil || sub == nil { + if err == nil { + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "subscription deleted", nil) + } + return + } + if !sub.Enabled || !s.settings.WebPushEnabled(ctx) { + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "subscription disabled", nil) + return + } + row, err := s.deliveries.GetRowByID(ctx, attempt.NotificationDeliveryID) + if err != nil || row == nil { + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "delivery row missing", nil) + return + } + + publicKey, privateKey, err := s.service.vapidKeys(ctx) + if err != nil { + s.logger.Error("VAPID keys unavailable", "error", err) + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "VAPID keys unavailable", nil) + return + } + + posterURL := "" + if s.payload != nil { + posterURL = s.payload(ctx, *row).PosterURL + } + message, err := buildWebPushPayload(*row, posterURL) + if err != nil { + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "payload build failed", nil) + return + } + + status, retryAfter, sendErr := s.send(ctx, sub, message, publicKey, privateKey) + attemptNumber := attempt.AttemptNumber + 1 + + if sendErr == nil && status >= 200 && status < 300 { + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeDelivered, + attemptNumber, &status, "", nil) + _ = s.subscriptions.RecordSuccess(ctx, sub.ID) + return + } + + if status == http.StatusNotFound || status == http.StatusGone { + // The browser unsubscribed or the registration expired: remove the + // subscription entirely (attempts cascade with it). + s.logger.Info("web push subscription gone; removing", + "subscription_id", sub.ID, "status", status) + _ = s.subscriptions.deleteGone(ctx, sub.ID) + return + } + + var statusPtr *int + if status > 0 { + statusPtr = &status + } + _ = s.subscriptions.RecordFailure(ctx, sub.ID, statusPtr) + + message_ := "push service error" + if sendErr != nil { + message_ = classifyWebhookError(sendErr) + } else if status > 0 { + message_ = fmt.Sprintf("HTTP %d", status) + } + + delay, more := webPushRetryDelay(attemptNumber) + if retryAfter > 0 { + delay = retryAfter + } + if more && (sendErr != nil || retryableHTTPStatus(status)) { + nextRetry := time.Now().Add(delay) + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeRetrying, + attemptNumber, statusPtr, message_, &nextRetry) + return + } + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attemptNumber, statusPtr, message_, nil) +} + +func (s *webPushSender) send(ctx context.Context, sub *WebPushSubscription, message []byte, publicKey, privateKey string) (status int, retryAfter time.Duration, err error) { + resp, err := webpush.SendNotificationWithContext(ctx, message, &webpush.Subscription{ + Endpoint: sub.Endpoint, + Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}, + }, &webpush.Options{ + HTTPClient: s.client, + Subscriber: "https://github.com/Silo-Server/silo-server", + TTL: webPushTTLSeconds, + Urgency: webpush.UrgencyNormal, + VAPIDPublicKey: publicKey, + VAPIDPrivateKey: privateKey, + }) + if err != nil { + return 0, 0, err + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 16<<10)) + if resp.StatusCode == http.StatusTooManyRequests { + if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 { + retryAfter = time.Duration(seconds) * time.Second + } + } + return resp.StatusCode, retryAfter, nil +} + +// WebPushDispatcher implements the channel Dispatcher interface: it hands +// delivery IDs to a bounded worker pool that claims and sends the pending +// outbox attempts. A full queue defers to the retry worker's recovery sweep. +type WebPushDispatcher struct { + sender *webPushSender + queue chan string + logger *slog.Logger +} + +func newWebPushDispatcher(sender *webPushSender) *WebPushDispatcher { + return &WebPushDispatcher{ + sender: sender, + queue: make(chan string, webhookDispatchQueue), + logger: slog.Default().With("component", "notifications.webpush.dispatch"), + } +} + +// Dispatch queues the delivery's web push attempts for immediate send. +func (d *WebPushDispatcher) Dispatch(_ context.Context, delivery DeliveryRow) error { + if d == nil { + return nil + } + select { + case d.queue <- delivery.ID: + default: + d.logger.Warn("web push dispatch queue full; deferring to retry worker", + "delivery_id", delivery.ID) + } + return nil +} + +// Run consumes the dispatch queue and the retry/recovery sweep until ctx is +// canceled. +func (d *WebPushDispatcher) Run(ctx context.Context) { + var wg sync.WaitGroup + for range webhookDispatchWorkers { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case deliveryID := <-d.queue: + d.processDelivery(ctx, deliveryID) + } + } + }() + } + + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(webhookRetryInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + if !d.sender.settings.WebPushEnabled(ctx) { + continue + } + for { + attempts, err := d.sender.subscriptions.ClaimDue(ctx, webhookRetryClaimLimit) + if err != nil { + if ctx.Err() == nil { + d.logger.Warn("web push retry claim failed", "error", err) + } + break + } + if len(attempts) == 0 { + break + } + for _, attempt := range attempts { + if ctx.Err() != nil { + return + } + d.sender.processAttempt(ctx, attempt) + } + } + } + }() + wg.Wait() +} + +func (d *WebPushDispatcher) processDelivery(ctx context.Context, deliveryID string) { + attempts, err := d.sender.subscriptions.ClaimPendingForDelivery(ctx, deliveryID) + if err != nil { + if ctx.Err() == nil { + d.logger.Warn("web push attempt claim failed", "delivery_id", deliveryID, "error", err) + } + return + } + for _, attempt := range attempts { + if ctx.Err() != nil { + return + } + d.sender.processAttempt(ctx, attempt) + } +} diff --git a/internal/notifications/webpush_service.go b/internal/notifications/webpush_service.go new file mode 100644 index 00000000..5ec0fcea --- /dev/null +++ b/internal/notifications/webpush_service.go @@ -0,0 +1,171 @@ +package notifications + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + + webpush "github.com/SherClockHolmes/webpush-go" + "github.com/oklog/ulid/v2" +) + +// VAPID key settings. The keypair is stored as a single JSON value (encrypted +// at rest via SensitiveSettingKeys) so both halves persist atomically: two +// concurrent provisioners can each lose the race wholesale but can never +// strand a mismatched public/private pair. Clients receive the public half +// from the capability endpoint, never from the settings store directly. +const ( + SettingWebPushEnabled = "notifications.web_push_enabled" + SettingWebPushVAPIDKeypair = "notifications.web_push.vapid_keypair" //nolint:gosec // setting key name, not a credential +) + +// vapidKeypair is the JSON shape persisted under SettingWebPushVAPIDKeypair. +type vapidKeypair struct { + Public string `json:"public"` + Private string `json:"private"` +} + +// WebPushEnabled gates the web push channel (kill switch). +func (s *Settings) WebPushEnabled(ctx context.Context) bool { + return s.boolSetting(ctx, SettingWebPushEnabled, true) +} + +// SettingWriter persists server settings. Satisfied by +// catalog.EncryptedSettingsRepo, which encrypts sensitive keys on write. +type SettingWriter interface { + Set(ctx context.Context, key, value string) error +} + +// ErrWebPushInvalid marks rejected subscription input. +var ErrWebPushInvalid = errors.New("invalid web push subscription") + +// WebPushService owns browser push subscriptions and the server's VAPID +// identity. VAPID keys are self-provisioned on first use — Web Push needs no +// third-party accounts, and payloads are end-to-end encrypted to the browser +// so the vendor push service never sees notification content. +type WebPushService struct { + repo *WebPushRepository + settings *Settings + writer SettingWriter + + mu sync.Mutex + publicKey string + privateKey string +} + +func newWebPushService(repo *WebPushRepository, settings *Settings, writer SettingWriter) *WebPushService { + return &WebPushService{repo: repo, settings: settings, writer: writer} +} + +// vapidKeys returns the server's VAPID keypair, generating and persisting one +// on first call. The keypair must stay stable for the server's lifetime: +// browsers bind subscriptions to the public key. +func (s *WebPushService) vapidKeys(ctx context.Context) (publicKey, privateKey string, err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.publicKey != "" && s.privateKey != "" { + return s.publicKey, s.privateKey, nil + } + + stored := s.loadKeypair(ctx) + if stored.Public == "" || stored.Private == "" { + if s.writer == nil { + return "", "", errors.New("web push requires a writable settings store") + } + private, public, genErr := webpush.GenerateVAPIDKeys() + if genErr != nil { + return "", "", fmt.Errorf("generate VAPID keys: %w", genErr) + } + data, marshalErr := json.Marshal(vapidKeypair{Public: public, Private: private}) + if marshalErr != nil { + return "", "", fmt.Errorf("encode VAPID keypair: %w", marshalErr) + } + if err := s.writer.Set(ctx, SettingWebPushVAPIDKeypair, string(data)); err != nil { + return "", "", fmt.Errorf("persist VAPID keypair: %w", err) + } + // Re-read and adopt whatever the store now holds: if another process + // provisioned concurrently, the last write won and every node must + // converge on that pair rather than caching its own loser. + stored = s.loadKeypair(ctx) + if stored.Public == "" || stored.Private == "" { + stored = vapidKeypair{Public: public, Private: private} + } + } + s.publicKey = stored.Public + s.privateKey = stored.Private + return s.publicKey, s.privateKey, nil +} + +// loadKeypair reads the persisted keypair directly from the settings reader, +// bypassing the Settings facade cache: provisioning must observe the latest +// stored value, not a seconds-old cached miss. A corrupt value reads as +// unprovisioned and is overwritten with a fresh pair. +func (s *WebPushService) loadKeypair(ctx context.Context) vapidKeypair { + var keys vapidKeypair + if s.settings == nil || s.settings.reader == nil { + return keys + } + raw, err := s.settings.reader.Get(ctx, SettingWebPushVAPIDKeypair) + if err != nil || strings.TrimSpace(raw) == "" { + return vapidKeypair{} + } + if err := json.Unmarshal([]byte(raw), &keys); err != nil { + return vapidKeypair{} + } + return keys +} + +// PublicKey returns the VAPID application server key clients subscribe with. +func (s *WebPushService) PublicKey(ctx context.Context) (string, error) { + publicKey, _, err := s.vapidKeys(ctx) + return publicKey, err +} + +// Subscribe registers (or reassigns) a browser PushSubscription for the +// profile. The endpoint must be an HTTPS URL on a public host — it is +// attacker-controllable input that the server will POST to. +func (s *WebPushService) Subscribe(ctx context.Context, userID int, profileID, endpoint, p256dh, auth, deviceName string) (*WebPushSubscription, error) { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" || p256dh == "" || auth == "" { + return nil, fmt.Errorf("%w: endpoint and keys are required", ErrWebPushInvalid) + } + if len(endpoint) > 2048 { + return nil, fmt.Errorf("%w: endpoint is too long", ErrWebPushInvalid) + } + if _, err := ValidateWebhookURL(endpoint, false); err != nil { + return nil, fmt.Errorf("%w: %s", ErrWebPushInvalid, err.Error()) + } + if len(deviceName) > 128 { + deviceName = deviceName[:128] + } + return s.repo.Upsert(ctx, WebPushSubscription{ + ID: ulid.Make().String(), + UserID: userID, + ProfileID: profileID, + Endpoint: endpoint, + P256dh: p256dh, + Auth: auth, + DeviceName: deviceName, + }) +} + +// List returns the profile's subscriptions. +func (s *WebPushService) List(ctx context.Context, profileID string) ([]WebPushSubscription, error) { + return s.repo.ListByProfile(ctx, profileID) +} + +// Unsubscribe removes a subscription by id (profile-scoped, settings UI) or +// by endpoint (user-scoped: the browser owns the endpoint and Subscribe may +// have reassigned it to a sibling profile). Idempotent. +func (s *WebPushService) Unsubscribe(ctx context.Context, userID int, profileID, id, endpoint string) error { + if id != "" { + return s.repo.Delete(ctx, profileID, id) + } + if endpoint != "" { + return s.repo.DeleteByEndpoint(ctx, userID, endpoint) + } + return fmt.Errorf("%w: an id or endpoint is required", ErrWebPushInvalid) +} diff --git a/internal/notifications/ws_ticket.go b/internal/notifications/ws_ticket.go new file mode 100644 index 00000000..b414ce92 --- /dev/null +++ b/internal/notifications/ws_ticket.go @@ -0,0 +1,128 @@ +package notifications + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +// TicketTTL is how long a websocket ticket stays valid. Browsers cannot set +// custom headers on websocket handshakes, so profile identity is carried by a +// short-lived single-use ticket in the query string. Reverse-proxy access +// logs commonly capture query strings; a logged ticket that expired seconds +// after minting is harmless where a logged profile token would not be. +const TicketTTL = 30 * time.Second + +// TicketStore mints and consumes single-use websocket handshake tickets +// bound to a (user, profile). +type TicketStore interface { + Mint(ctx context.Context, userID int, profileID string) (ticket string, ttl time.Duration, err error) + // Consume validates and invalidates a ticket. ok is false for missing, + // expired, or already-used tickets. + Consume(ctx context.Context, ticket string) (userID int, profileID string, ok bool) +} + +// NewTicketStore returns a Redis-backed store when a Redis client is +// available (multi-node websocket serving) and an in-memory store otherwise. +func NewTicketStore(redisClient *redis.Client) TicketStore { + if redisClient != nil { + return &redisTicketStore{client: redisClient} + } + return &memoryTicketStore{tickets: make(map[string]memoryTicket)} +} + +func newTicketValue() (string, error) { + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate ticket: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +type memoryTicket struct { + userID int + profileID string + expiresAt time.Time +} + +type memoryTicketStore struct { + mu sync.Mutex + tickets map[string]memoryTicket +} + +func (s *memoryTicketStore) Mint(_ context.Context, userID int, profileID string) (string, time.Duration, error) { + ticket, err := newTicketValue() + if err != nil { + return "", 0, err + } + now := time.Now() + s.mu.Lock() + // Opportunistic sweep keeps the map bounded without a janitor goroutine. + for key, entry := range s.tickets { + if now.After(entry.expiresAt) { + delete(s.tickets, key) + } + } + s.tickets[ticket] = memoryTicket{ + userID: userID, + profileID: profileID, + expiresAt: now.Add(TicketTTL), + } + s.mu.Unlock() + return ticket, TicketTTL, nil +} + +func (s *memoryTicketStore) Consume(_ context.Context, ticket string) (int, string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.tickets[ticket] + if !ok { + return 0, "", false + } + delete(s.tickets, ticket) // single-use + if time.Now().After(entry.expiresAt) { + return 0, "", false + } + return entry.userID, entry.profileID, true +} + +type redisTicketStore struct { + client *redis.Client +} + +const redisTicketPrefix = "silo:events:ws-ticket:" + +func (s *redisTicketStore) Mint(ctx context.Context, userID int, profileID string) (string, time.Duration, error) { + ticket, err := newTicketValue() + if err != nil { + return "", 0, err + } + value := strconv.Itoa(userID) + "|" + profileID + if err := s.client.Set(ctx, redisTicketPrefix+ticket, value, TicketTTL).Err(); err != nil { + return "", 0, fmt.Errorf("store ticket: %w", err) + } + return ticket, TicketTTL, nil +} + +func (s *redisTicketStore) Consume(ctx context.Context, ticket string) (int, string, bool) { + value, err := s.client.GetDel(ctx, redisTicketPrefix+ticket).Result() + if err != nil { + return 0, "", false + } + parts := strings.SplitN(value, "|", 2) + if len(parts) != 2 { + return 0, "", false + } + userID, err := strconv.Atoi(parts[0]) + if err != nil || userID <= 0 { + return 0, "", false + } + return userID, parts[1], true +} diff --git a/internal/taskmanager/tasks/notifications_tasks.go b/internal/taskmanager/tasks/notifications_tasks.go new file mode 100644 index 00000000..a2c0e69d --- /dev/null +++ b/internal/taskmanager/tasks/notifications_tasks.go @@ -0,0 +1,142 @@ +package tasks + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +// SeedEpisodeAvailabilityTask inserts episode_availability rows for every +// currently playable episode without creating release events, then writes the +// per-library seed markers. Running it is what allows release events to flow +// for libraries that predate the notifications feature; rerunning is a cheap +// idempotent repair pass. +type SeedEpisodeAvailabilityTask struct { + system *notifications.System +} + +// NewSeedEpisodeAvailabilityTask creates the seeding task. +func NewSeedEpisodeAvailabilityTask(system *notifications.System) *SeedEpisodeAvailabilityTask { + return &SeedEpisodeAvailabilityTask{system: system} +} + +func (t *SeedEpisodeAvailabilityTask) Key() string { return "seed_episode_availability" } +func (t *SeedEpisodeAvailabilityTask) Name() string { return "Seed Episode Availability" } +func (t *SeedEpisodeAvailabilityTask) Description() string { + return "Records the existing episode back-catalog as already-released so new-episode notifications only fire for episodes that arrive afterwards." +} +func (t *SeedEpisodeAvailabilityTask) Category() taskmanager.TaskCategory { + return taskmanager.TaskCategorySystem +} +func (t *SeedEpisodeAvailabilityTask) IsHidden() bool { return true } + +func (t *SeedEpisodeAvailabilityTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeStartup}} +} + +func (t *SeedEpisodeAvailabilityTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + if t == nil || t.system == nil { + progress.Report(100, "Notifications are not configured") + return nil + } + progress.Report(0, "Seeding episode availability") + if err := t.system.SeedAvailability(ctx, func(percent int, message string) { + progress.Report(float64(percent), message) + }); err != nil { + return fmt.Errorf("seeding episode availability: %w", err) + } + progress.Report(100, "Episode availability seeded") + return nil +} + +// RebuildReleaseInterestTask rebuilds profile_series_interest from favorites, +// watchlist, and watch progress. It is the rollout backfill and the periodic +// drift-repair pass; recomputes share the same code as live updates. +type RebuildReleaseInterestTask struct { + system *notifications.System +} + +// NewRebuildReleaseInterestTask creates the interest rebuild task. +func NewRebuildReleaseInterestTask(system *notifications.System) *RebuildReleaseInterestTask { + return &RebuildReleaseInterestTask{system: system} +} + +func (t *RebuildReleaseInterestTask) Key() string { return "rebuild_release_interest" } +func (t *RebuildReleaseInterestTask) Name() string { return "Rebuild Notification Interest" } +func (t *RebuildReleaseInterestTask) Description() string { + return "Recomputes which profiles care about which series (favorites, watchlist, watch progress) for new-episode notifications." +} +func (t *RebuildReleaseInterestTask) Category() taskmanager.TaskCategory { + return taskmanager.TaskCategorySystem +} +func (t *RebuildReleaseInterestTask) IsHidden() bool { return true } + +func (t *RebuildReleaseInterestTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{ + {Type: taskmanager.TriggerTypeStartup}, + {Type: taskmanager.TriggerTypeDaily, TimeOfDay: "04:30"}, + } +} + +func (t *RebuildReleaseInterestTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + if t == nil || t.system == nil { + progress.Report(100, "Notifications are not configured") + return nil + } + progress.Report(0, "Rebuilding profile series interest") + if err := t.system.RebuildInterest(ctx, func(percent int, message string) { + progress.Report(float64(percent), message) + }); err != nil { + return fmt.Errorf("rebuilding notification interest: %w", err) + } + progress.Report(100, "Notification interest rebuilt") + return nil +} + +// NotificationsRetentionTask applies the notification retention policy: read +// inbox rows past the read window, unread rows past the unread window, +// processed release events past the debug window, and inert interest rows. +type NotificationsRetentionTask struct { + system *notifications.System +} + +// NewNotificationsRetentionTask creates the retention task. +func NewNotificationsRetentionTask(system *notifications.System) *NotificationsRetentionTask { + return &NotificationsRetentionTask{system: system} +} + +func (t *NotificationsRetentionTask) Key() string { return "notifications_retention" } +func (t *NotificationsRetentionTask) Name() string { return "Clean Up Notifications" } +func (t *NotificationsRetentionTask) Description() string { + return "Prunes old notifications and processed release events according to the retention settings." +} +func (t *NotificationsRetentionTask) Category() taskmanager.TaskCategory { + return taskmanager.TaskCategorySystem +} +func (t *NotificationsRetentionTask) IsHidden() bool { return false } + +func (t *NotificationsRetentionTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeDaily, TimeOfDay: "05:00"}} +} + +func (t *NotificationsRetentionTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + if t == nil || t.system == nil { + progress.Report(100, "Notifications are not configured") + return nil + } + progress.Report(0, "Applying notification retention policy") + stats, err := t.system.RunRetention(ctx) + if err != nil { + return fmt.Errorf("notification retention: %w", err) + } + if data, err := json.Marshal(stats); err == nil { + progress.SetResultData(data) + } + progress.Report(100, fmt.Sprintf( + "Removed %d notifications, %d release events, %d inert interest rows", + stats.DeliveriesDeleted, stats.EventsDeleted, stats.InterestPruned)) + return nil +} diff --git a/migrations/sql/20260611100000_profile_release_notifications.sql b/migrations/sql/20260611100000_profile_release_notifications.sql new file mode 100644 index 00000000..1aa996ad --- /dev/null +++ b/migrations/sql/20260611100000_profile_release_notifications.sql @@ -0,0 +1,161 @@ +-- +goose Up +-- +goose StatementBegin +-- Foundation schema for profile-scoped release notifications +-- (docs/superpowers/plans/notifications/01-release-events-and-inbox.md). +-- +-- episode_availability is a one-way "episode first became available in this +-- library" fact. Rows are inserted by live ingest and by silent seeding +-- (initial library scans, feature-enable backfill); they persist across file +-- churn so re-added files never re-notify. +CREATE TABLE public.episode_availability ( + library_id integer NOT NULL, + episode_id text NOT NULL, + series_id text NOT NULL, + season_number integer NOT NULL, + episode_number integer NOT NULL, + episode_key integer NOT NULL, + available_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT episode_availability_pkey PRIMARY KEY (library_id, episode_id) +); + +CREATE INDEX episode_availability_series_idx + ON public.episode_availability (library_id, series_id, episode_key DESC); + +-- Per-library marker that availability seeding completed. Release events are +-- emitted only for libraries with a row here; unseeded libraries insert +-- availability silently ("newly available" means newly released to this +-- server, not newly seen by the notifications feature). +CREATE TABLE public.notification_library_seed_state ( + library_id integer PRIMARY KEY REFERENCES public.media_folders(id) ON DELETE CASCADE, + seeded_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE public.release_events ( + id text PRIMARY KEY, + library_id integer NOT NULL, + series_id text NOT NULL, + episode_id text NOT NULL, + season_number integer NOT NULL, + episode_number integer NOT NULL, + episode_key integer NOT NULL, + available_at timestamptz NOT NULL, + -- Explicit column (rather than a composite unique) so future event kinds + -- can share the table with their own key shapes. Composed as + -- "{library_id}:{episode_id}". + dedupe_key text NOT NULL, + processed_at timestamptz, + -- NULL for fanned-out events; 'series_burst' when the per-series burst + -- cap consumed this event without fanout. + suppressed_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT release_events_dedupe_key_key UNIQUE (dedupe_key) +); + +CREATE INDEX release_events_unprocessed_idx + ON public.release_events (processed_at, created_at); +CREATE INDEX release_events_series_idx + ON public.release_events (library_id, series_id, created_at DESC); + +-- Compact recipient index used by the fanout worker. profile_id has no FK: +-- profiles may live in per-user SQLite stores rather than Postgres, so +-- profile deletion cleans these rows up in code instead of via cascade. +CREATE TABLE public.profile_series_interest ( + user_id integer NOT NULL, + profile_id text NOT NULL, + library_id integer NOT NULL, + series_id text NOT NULL, + favorite boolean NOT NULL DEFAULT false, + watchlist boolean NOT NULL DEFAULT false, + continue_watching boolean NOT NULL DEFAULT false, + next_up_candidate boolean NOT NULL DEFAULT false, + last_completed_episode_key integer, + next_expected_episode_key integer, + last_notified_episode_key integer, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT profile_series_interest_pkey PRIMARY KEY (profile_id, library_id, series_id) +); + +CREATE INDEX profile_series_interest_series_idx + ON public.profile_series_interest (library_id, series_id); +-- Hot fanout path: only rows with at least one active interest flag matter. +CREATE INDEX profile_series_interest_active_idx + ON public.profile_series_interest (library_id, series_id) + WHERE favorite OR watchlist OR continue_watching OR next_up_candidate; +CREATE INDEX profile_series_interest_profile_idx + ON public.profile_series_interest (profile_id, updated_at DESC); + +-- Durable per-profile inbox rows. release_event_id is nullable: operational +-- types (e.g. webhook.auto_disabled) have no release event, and retention +-- pruning of old release_events must not delete inbox rows. +CREATE TABLE public.notification_deliveries ( + id text PRIMARY KEY, + release_event_id text REFERENCES public.release_events(id) ON DELETE SET NULL, + user_id integer NOT NULL, + profile_id text NOT NULL, + library_id integer, + series_id text, + episode_id text, + type text NOT NULL, + reason_flags jsonb NOT NULL, + status text NOT NULL DEFAULT 'delivered', + read_at timestamptz, + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT notification_deliveries_episode_fields_check CHECK ( + type <> 'episode.available' + OR (release_event_id IS NOT NULL AND library_id IS NOT NULL + AND series_id IS NOT NULL AND episode_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX notification_deliveries_profile_event_key + ON public.notification_deliveries (profile_id, release_event_id) + WHERE release_event_id IS NOT NULL; +-- Cross-library dedupe: the same episode landing in two libraries (e.g. +-- "TV" and "TV 4K") shares one episode_id; the first release event processed +-- wins and later inserts no-op. +CREATE UNIQUE INDEX notification_deliveries_profile_episode_key + ON public.notification_deliveries (profile_id, episode_id) + WHERE type = 'episode.available'; + +CREATE INDEX notification_deliveries_inbox_idx + ON public.notification_deliveries (profile_id, created_at DESC); +CREATE INDEX notification_deliveries_unread_idx + ON public.notification_deliveries (profile_id, read_at, created_at DESC); +CREATE INDEX notification_deliveries_status_idx + ON public.notification_deliveries (status, created_at); +-- Forward-sync cursor support. +CREATE INDEX notification_deliveries_sync_idx + ON public.notification_deliveries (created_at, id); + +CREATE TABLE public.notification_preferences ( + profile_id text PRIMARY KEY, + enabled boolean NOT NULL DEFAULT true, + notify_favorites boolean NOT NULL DEFAULT true, + notify_watchlist boolean NOT NULL DEFAULT true, + notify_continue_watching boolean NOT NULL DEFAULT true, + notify_next_up boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Checkpoint state for the interest backfill / availability seeding tasks. +CREATE TABLE public.notification_backfill_state ( + task text PRIMARY KEY, + last_processed_key text, + started_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS public.notification_backfill_state; +DROP TABLE IF EXISTS public.notification_preferences; +DROP TABLE IF EXISTS public.notification_deliveries; +DROP TABLE IF EXISTS public.profile_series_interest; +DROP TABLE IF EXISTS public.release_events; +DROP TABLE IF EXISTS public.notification_library_seed_state; +DROP TABLE IF EXISTS public.episode_availability; +-- +goose StatementEnd diff --git a/migrations/sql/20260611120000_notification_webhooks.sql b/migrations/sql/20260611120000_notification_webhooks.sql new file mode 100644 index 00000000..ac7208f7 --- /dev/null +++ b/migrations/sql/20260611120000_notification_webhooks.sql @@ -0,0 +1,76 @@ +-- +goose Up +-- +goose StatementBegin +-- Outbound webhooks channel for release notifications +-- (docs/superpowers/plans/notifications/04-outbound-webhooks.md). +-- +-- url_ciphertext / signing_secret_ciphertext hold enc:v1: envelopes produced +-- by internal/secret (text, not bytea, matching the repo's at-rest cipher +-- convention). profile_id has no FK: profiles may live in per-user SQLite +-- stores; deletion cleans up in code. +CREATE TABLE public.notification_webhooks ( + id text PRIMARY KEY, + user_id integer NOT NULL, + profile_id text NOT NULL, + name varchar(64) NOT NULL, + type text NOT NULL, + url_ciphertext text NOT NULL, + url_host varchar(253) NOT NULL, + signing_secret_ciphertext text, + enabled boolean NOT NULL DEFAULT true, + notify_favorites boolean NOT NULL DEFAULT true, + notify_watchlist boolean NOT NULL DEFAULT true, + notify_continue_watching boolean NOT NULL DEFAULT true, + notify_next_up boolean NOT NULL DEFAULT true, + consecutive_failures integer NOT NULL DEFAULT 0, + disabled_reason varchar(256), + last_success_at timestamptz, + last_failure_at timestamptz, + last_failure_status integer, + last_failure_message varchar(256), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT notification_webhooks_profile_name_key UNIQUE (profile_id, name), + CONSTRAINT notification_webhooks_type_check CHECK (type IN ('discord', 'generic')), + CONSTRAINT notification_webhooks_secret_check CHECK (type = 'discord' OR signing_secret_ciphertext IS NOT NULL) +); + +CREATE INDEX notification_webhooks_profile_idx + ON public.notification_webhooks (profile_id); +CREATE INDEX notification_webhooks_profile_enabled_idx + ON public.notification_webhooks (profile_id) + WHERE enabled; + +-- Durable dispatch outbox + retry state. `pending` rows are enqueued in the +-- fanout transaction; the post-commit dispatcher claims them, and the retry +-- worker sweeps stale pending rows (crash between commit and dispatch) plus +-- due retries. +CREATE TABLE public.webhook_delivery_attempts ( + id text PRIMARY KEY, + notification_delivery_id text NOT NULL REFERENCES public.notification_deliveries(id) ON DELETE CASCADE, + webhook_id text NOT NULL REFERENCES public.notification_webhooks(id) ON DELETE CASCADE, + attempt_number integer NOT NULL, + attempted_at timestamptz NOT NULL DEFAULT now(), + next_retry_at timestamptz, + http_status integer, + outcome text NOT NULL, + failure_message varchar(256), + CONSTRAINT webhook_delivery_attempts_unique UNIQUE (webhook_id, notification_delivery_id, attempt_number), + CONSTRAINT webhook_delivery_attempts_outcome_check CHECK (outcome IN ('pending', 'delivered', 'retrying', 'failed', 'auto_disabled')) +); + +CREATE INDEX webhook_delivery_attempts_history_idx + ON public.webhook_delivery_attempts (webhook_id, attempted_at DESC); +CREATE INDEX webhook_delivery_attempts_retry_idx + ON public.webhook_delivery_attempts (outcome, next_retry_at); +-- Serves the per-delivery claim (ClaimPendingForDelivery) and, critically, +-- the ON DELETE CASCADE from notification_deliveries: without it every +-- retention delete seq-scans this table once per deleted delivery row. +CREATE INDEX webhook_delivery_attempts_delivery_idx + ON public.webhook_delivery_attempts (notification_delivery_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS public.webhook_delivery_attempts; +DROP TABLE IF EXISTS public.notification_webhooks; +-- +goose StatementEnd diff --git a/migrations/sql/20260611150000_web_push_subscriptions.sql b/migrations/sql/20260611150000_web_push_subscriptions.sql new file mode 100644 index 00000000..69e6378c --- /dev/null +++ b/migrations/sql/20260611150000_web_push_subscriptions.sql @@ -0,0 +1,65 @@ +-- +goose Up +-- +goose StatementBegin +-- Web Push channel for release notifications. Browser PushSubscriptions are +-- profile-scoped; payloads are end-to-end encrypted (RFC 8291) so the +-- browser vendor's push service never sees notification content. profile_id +-- has no FK: profiles may live in per-user SQLite stores; deletion cleans up +-- in code. +CREATE TABLE public.web_push_subscriptions ( + id text PRIMARY KEY, + user_id integer NOT NULL, + profile_id text NOT NULL, + -- The push-service URL is unique per browser registration. A + -- resubscription from the same browser under a different profile + -- reassigns the row (one endpoint notifies exactly one profile). + endpoint text NOT NULL, + p256dh text NOT NULL, + auth text NOT NULL, + device_name varchar(128) NOT NULL DEFAULT '', + enabled boolean NOT NULL DEFAULT true, + consecutive_failures integer NOT NULL DEFAULT 0, + last_success_at timestamptz, + last_failure_at timestamptz, + last_failure_status integer, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT web_push_subscriptions_endpoint_key UNIQUE (endpoint) +); + +CREATE INDEX web_push_subscriptions_profile_idx + ON public.web_push_subscriptions (profile_id); +CREATE INDEX web_push_subscriptions_profile_enabled_idx + ON public.web_push_subscriptions (profile_id) + WHERE enabled; + +-- Durable dispatch outbox + retry state, mirroring webhook_delivery_attempts: +-- `pending` rows are enqueued in the fanout transaction, claimed post-commit, +-- and swept by the retry worker after a crash. +CREATE TABLE public.web_push_delivery_attempts ( + id text PRIMARY KEY, + notification_delivery_id text NOT NULL REFERENCES public.notification_deliveries(id) ON DELETE CASCADE, + subscription_id text NOT NULL REFERENCES public.web_push_subscriptions(id) ON DELETE CASCADE, + attempt_number integer NOT NULL, + attempted_at timestamptz NOT NULL DEFAULT now(), + next_retry_at timestamptz, + http_status integer, + outcome text NOT NULL, + failure_message varchar(256), + CONSTRAINT web_push_delivery_attempts_unique UNIQUE (subscription_id, notification_delivery_id, attempt_number), + CONSTRAINT web_push_delivery_attempts_outcome_check CHECK (outcome IN ('pending', 'delivered', 'retrying', 'failed')) +); + +CREATE INDEX web_push_delivery_attempts_retry_idx + ON public.web_push_delivery_attempts (outcome, next_retry_at); +-- Serves the per-delivery claim (ClaimPendingForDelivery) and, critically, +-- the ON DELETE CASCADE from notification_deliveries: without it every +-- retention delete seq-scans this table once per deleted delivery row. +CREATE INDEX web_push_delivery_attempts_delivery_idx + ON public.web_push_delivery_attempts (notification_delivery_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS public.web_push_delivery_attempts; +DROP TABLE IF EXISTS public.web_push_subscriptions; +-- +goose StatementEnd diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 00000000..a20dda06 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,49 @@ +/** + * Silo service worker: displays Web Push notifications and routes clicks. + * Payloads arrive end-to-end encrypted (RFC 8291); by the time the push + * event fires the browser has decrypted them for us. + */ + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = {}; + } + const title = data.title || "Silo"; + const options = { + body: data.body || "", + icon: data.icon || "/web-app-icon-192.png", + badge: "/web-app-icon-192.png", + tag: data.tag || undefined, + data: { url: data.url || "/notifications" }, + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || "/notifications"; + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if ("focus" in client) { + if ("navigate" in client) { + client.navigate(url); + } + return client.focus(); + } + } + return self.clients.openWindow(url); + }), + ); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index e829b513..e0387de0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -37,6 +37,8 @@ import EbookReader from "@/pages/EbookReader"; import PersonDetail from "@/pages/PersonDetail"; import Collections from "@/pages/Collections"; import CollectionEditor from "@/pages/CollectionEditor"; +import Notifications from "@/pages/Notifications"; +import NotificationsSettings from "@/pages/settings/NotificationsSettings"; import Requests from "@/pages/Requests"; import RequestBrowse from "@/pages/RequestBrowse"; import RequestDetail from "@/pages/RequestDetail"; @@ -246,6 +248,7 @@ function QueryCacheManager() { qc.removeQueries({ queryKey: ["sections"] }); qc.removeQueries({ queryKey: ["calendar"] }); qc.removeQueries({ queryKey: ["requests"] }); + qc.removeQueries({ queryKey: ["notifications"] }); // Recommendation rows include per-profile user_state (is_favorite, etc.); // the taste-seed picker depends on this for pre-selection. qc.removeQueries({ queryKey: ["recommendations"] }); @@ -442,6 +445,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> + } /> } @@ -559,6 +564,9 @@ function RealtimeEventChannels() { useEventChannel("catalog"); useEventChannel("user_state"); + // Profile-scoped; the server rejects the subscription until the connection + // is bound to a profile via the websocket ticket, which is harmless. + useEventChannel("notifications"); return actingAdmin ? : null; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 7b90f7c2..55e00409 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -2221,7 +2221,122 @@ export type EventChannel = | "tasks" | "scans" | "history_import" - | "user_state"; + | "user_state" + | "notifications"; + +export interface NotificationReasonFlags { + favorite?: boolean; + watchlist?: boolean; + continue_watching?: boolean; + next_up?: boolean; +} + +export interface AppNotification { + id: string; + type: string; + profile_id: string; + library_id?: number; + series_id?: string; + episode_id?: string; + series_title?: string; + episode_title?: string; + season_number?: number; + episode_number?: number; + poster_path?: string; + poster_url?: string; + poster_thumbhash?: string; + reason_flags: NotificationReasonFlags; + created_at: string; + read_at: string | null; +} + +export interface NotificationListResponse { + notifications: AppNotification[]; + next_cursor?: string; +} + +export interface NotificationSyncResponse { + notifications: AppNotification[]; + next_cursor?: string; + unread_count: number; +} + +export interface NotificationUnreadCountResponse { + count: number; +} + +export interface NotificationPreferences { + profile_id: string; + enabled: boolean; + notify_favorites: boolean; + notify_watchlist: boolean; + notify_continue_watching: boolean; + notify_next_up: boolean; +} + +export interface NotificationReadEventPayload { + profile_id: string; + id?: string; + all?: boolean; +} + +export type NotificationWebhookType = "discord" | "generic"; + +export interface NotificationWebhook { + id: string; + name: string; + type: NotificationWebhookType; + url_host: string; + enabled: boolean; + notify_favorites: boolean; + notify_watchlist: boolean; + notify_continue_watching: boolean; + notify_next_up: boolean; + consecutive_failures: number; + disabled_reason: string | null; + last_success_at: string | null; + last_failure_at: string | null; + last_failure_status: number | null; + last_failure_message: string | null; + /** Present only in create / rotate-secret responses; shown exactly once. */ + signing_secret?: string; +} + +export interface NotificationWebhookInput { + name?: string; + url?: string; + type?: NotificationWebhookType; + enabled?: boolean; + notify_favorites?: boolean; + notify_watchlist?: boolean; + notify_continue_watching?: boolean; + notify_next_up?: boolean; +} + +export interface NotificationWebhookTestResult { + ok: boolean; + http_status?: number; + duration_ms: number; + message?: string; +} + +export interface NotificationCapability { + in_app: { enabled: boolean }; + apple_push: { available: boolean; provider: string; supported_modes: string[] }; + android_push: { available: boolean; provider: string; supported_modes: string[] }; + web_push: { available: boolean; public_key?: string }; + webhooks: { available: boolean; max_per_profile: number; supported_types: string[] }; +} + +export interface WebPushSubscriptionView { + id: string; + endpoint: string; + device_name?: string; + enabled: boolean; + created_at: string; + last_success_at: string | null; + last_failure_at: string | null; +} export interface EventsHelloMessage { type: "hello"; diff --git a/web/src/components/AppSidebar.test.tsx b/web/src/components/AppSidebar.test.tsx index 49068750..c90a22e5 100644 --- a/web/src/components/AppSidebar.test.tsx +++ b/web/src/components/AppSidebar.test.tsx @@ -56,6 +56,14 @@ vi.mock("@/hooks/queries/useRequests", () => ({ }), })); +vi.mock("@/hooks/queries/notifications", () => ({ + useUnreadNotificationCount: () => ({ data: 0 }), +})); + +vi.mock("@/hooks/queries/notificationWebhooks", () => ({ + useNotificationCapability: () => ({ data: { in_app: { enabled: true } }, isError: false }), +})); + vi.mock("@/hooks/useViewTransition", () => ({ useViewTransitionNavigate: () => vi.fn(), })); diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index d3addcd6..e0d81fa6 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -9,6 +9,8 @@ import { useCurrentProfile } from "@/hooks/useCurrentProfile"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import { navigateToPluginRoute } from "@/lib/buildPluginHref"; import { useUserLibraries } from "@/hooks/queries/libraries"; +import { useUnreadNotificationCount } from "@/hooks/queries/notifications"; +import { useNotificationCapability } from "@/hooks/queries/notificationWebhooks"; import { usePluginSettingsList } from "@/hooks/queries/pluginSettings"; import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; import { useSidebarPins, useToggleSidebarPin } from "@/hooks/queries/sidebarPins"; @@ -54,6 +56,7 @@ import { Puzzle, BookHeadphones, Send, + Bell, } from "lucide-react"; import { useTheme } from "@/hooks/useTheme"; import { CURATED_THEME_IDS, THEMES } from "@/lib/themes"; @@ -175,6 +178,16 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar const { data: pluginSettings } = usePluginSettingsList(); const requestStatus = useRequestFeatureStatus(); const showRequestsNav = requestStatus.data?.requests_enabled === true; + // Optimistic while loading (the setting defaults to on, so hiding until the + // capability resolves would flash); hidden when the admin kill switch is off + // or the server has no notifications API (worker modes → query errors). + const notificationCapability = useNotificationCapability(); + const showNotificationsNav = notificationCapability.isError + ? false + : (notificationCapability.data?.in_app.enabled ?? true); + const { data: unreadNotifications } = useUnreadNotificationCount( + Boolean(profile) && showNotificationsNav, + ); const pluginNavLinks = useMemo(() => { const installations = pluginSettings?.installations ?? []; const links: { id: string; basePath: string; label: string; pluginId: string }[] = []; @@ -552,6 +565,41 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar Calendar + {showNotificationsNav && ( +
  • + + {isActive("/notifications") && ( + + )} + + + {!showLabels && (unreadNotifications ?? 0) > 0 && ( + + )} + + Notifications + {showLabels && (unreadNotifications ?? 0) > 0 && ( + + {(unreadNotifications ?? 0) > 99 ? "99+" : unreadNotifications} + + )} + +
  • + )} diff --git a/web/src/components/RealtimeEventsProvider.tsx b/web/src/components/RealtimeEventsProvider.tsx index 528c77b4..3e6a071b 100644 --- a/web/src/components/RealtimeEventsProvider.tsx +++ b/web/src/components/RealtimeEventsProvider.tsx @@ -3,15 +3,24 @@ import { useQueryClient } from "@tanstack/react-query"; import type { AdminJob, AdminSession, + AppNotification, EventChannel, EventsEventMessage, EventsSnapshotMessage, EventsStreamMessage, HistoryImportRun, + NotificationReadEventPayload, ScanRun, TaskInfo, } from "@/api/types"; import { api, getAccessToken } from "@/api/client"; +import { + applyNotificationCreated, + applyNotificationRead, + applyNotificationsSnapshot, + formatEpisodeCode, +} from "@/hooks/queries/notifications"; +import { toast } from "sonner"; import { RealtimeEventsContext, type EventChannelHandlers, @@ -61,15 +70,47 @@ const DASHBOARD_QUERY_KEYS = [ adminKeys.users(), ] as const; -function buildEventsUrl(token: string | null, location: Pick) { +function buildEventsUrl( + token: string | null, + location: Pick, + ticket?: string | null, +) { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; const search = new URLSearchParams(); if (token) { search.set("token", token); } + if (ticket) { + search.set("ticket", ticket); + } return `${protocol}//${location.host}/api/v1/events/ws${search.toString() ? `?${search.toString()}` : ""}`; } +/** + * Mints a short-lived single-use websocket ticket binding the connection to + * the active profile (required for the notifications channel). Returns null + * when no profile is active or the mint fails — the connection then proceeds + * unbound, and the subscribed-message handler retries the binding with + * backoff when the notifications subscription is rejected. + */ +async function mintEventsTicket(hasProfile: boolean): Promise { + if (!hasProfile) { + return null; + } + try { + const response = await api<{ ticket: string }>("/events/ws-ticket", { + method: "POST", + // A hung mint must settle: connect() awaits this before any socket + // exists, so without a timeout no onclose fires and no reconnect is + // ever scheduled — realtime would stay "connecting" forever. + signal: AbortSignal.timeout(10_000), + }); + return response.ticket || null; + } catch { + return null; + } +} + function parseEventsMessage(value: unknown): EventsStreamMessage | null { if (typeof value !== "string") { return null; @@ -359,6 +400,8 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { const allowDashboardRealtimeUpdates = !isDashboardRoute || pageActivity.canPollDashboard; const [connectionState, setConnectionState] = useState("connecting"); const reconnectTimerRef = useRef(undefined); + const profileRebindAttemptsRef = useRef(0); + const nextReconnectDelayRef = useRef(null); const socketRef = useRef(null); const helloReceivedRef = useRef(false); const requestCounterRef = useRef(0); @@ -459,12 +502,41 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { case "history_import": updateHistoryImportCaches(queryClient); break; + case "notifications": + if (Array.isArray(message.data)) { + applyNotificationsSnapshot(queryClient, message.data as AppNotification[]); + } + break; default: break; } dispatchChannelMessage(message.channel, "snapshot", message); } + function handleNotificationEvent(message: EventsEventMessage) { + if (message.event === "notification.created") { + const notification = message.data as AppNotification; + if ( + notification.profile_id && + activeProfileIDRef.current && + notification.profile_id !== activeProfileIDRef.current + ) { + return; + } + applyNotificationCreated(queryClient, notification); + if (notification.type === "episode.available" && notification.series_title) { + const episodeCode = formatEpisodeCode(notification); + toast(`New episode of ${notification.series_title}`, { + description: [episodeCode, notification.episode_title].filter(Boolean).join(" — "), + }); + } + return; + } + if (message.event === "notification.read") { + applyNotificationRead(queryClient, message.data as NotificationReadEventPayload); + } + } + function handleEvent(message: EventsEventMessage) { switch (message.channel) { case "catalog": @@ -531,6 +603,9 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { allowDashboardRealtimeUpdatesRef.current, ); break; + case "notifications": + handleNotificationEvent(message); + break; default: break; } @@ -576,13 +651,17 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { if (closedByEffect || reconnectTimerRef.current !== undefined) { return; } + // The profile-rebind path stretches the delay so a persistently failing + // ticket mint cannot turn into a tight reconnect loop. + const delay = nextReconnectDelayRef.current ?? 1_000; + nextReconnectDelayRef.current = null; reconnectTimerRef.current = window.setTimeout(() => { reconnectTimerRef.current = undefined; if (closedByEffect) { return; } connect(); - }, 1_000); + }, delay); }; const connect = () => { @@ -592,9 +671,25 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { setConnectionState("connecting"); helloReceivedRef.current = false; + // The ticket binds the connection to the active profile so the server + // can authorize the notifications channel. Failure degrades gracefully + // to an unbound connection; without a profile we connect synchronously. + if (!activeProfileIDRef.current) { + openSocket(null); + return; + } + void mintEventsTicket(true).then((ticket) => { + if (closedByEffect) { + return; + } + openSocket(ticket); + }); + }; + + const openSocket = (ticket: string | null) => { let socket: WebSocket; try { - socket = new WebSocket(buildEventsUrl(getAccessToken(), window.location)); + socket = new WebSocket(buildEventsUrl(getAccessToken(), window.location, ticket)); } catch { setConnectionState("disconnected"); scheduleReconnect(); @@ -628,8 +723,26 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { helloReceivedRef.current = true; sendSubscribe(); return; - case "subscribed": + case "subscribed": { + // A profile_required rejection means the profile binding was lost + // (the ticket mint failed or the ticket was not honored). Left + // alone, this socket would stay healthy for hours while silently + // delivering no notifications — reconnect with backoff to re-mint. + const profileRequired = (message.rejected ?? []).some( + (entry) => entry.channel === "notifications" && entry.code === "profile_required", + ); + if (profileRequired && activeProfileIDRef.current) { + profileRebindAttemptsRef.current += 1; + nextReconnectDelayRef.current = Math.min( + 30_000, + 1_000 * 2 ** Math.min(profileRebindAttemptsRef.current, 5), + ); + socket.close(); + } else { + profileRebindAttemptsRef.current = 0; + } return; + } case "snapshot": handleSnapshot(message); return; @@ -684,7 +797,10 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { socket.close(); } }; - }, [authenticatedUserID, pageActivity.canApplyRealtimeUpdates, queryClient]); + // profile?.id is a dependency on purpose: the websocket binds to the + // active profile via the handshake ticket, so a profile switch must + // reconnect (and resubscribe) under the new identity. + }, [authenticatedUserID, profile?.id, pageActivity.canApplyRealtimeUpdates, queryClient]); const value = useMemo( () => ({ diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 7aa8a5bd..6639a71c 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -221,6 +221,16 @@ export const settingsKeys = { pluginDetail: (installationId: number) => ["settings", "plugins", installationId] as const, }; +export const notificationKeys = { + all: ["notifications"] as const, + list: (status: "all" | "unread" = "all") => ["notifications", "list", status] as const, + unreadCount: () => ["notifications", "unread-count"] as const, + preferences: () => ["notifications", "preferences"] as const, + capability: () => ["notifications", "capability"] as const, + webhooks: () => ["notifications", "webhooks"] as const, + webPushSubscriptions: () => ["notifications", "web-push-subscriptions"] as const, +}; + export const historyImportKeys = { all: ["history-imports"] as const, sources: () => ["history-imports", "sources"] as const, diff --git a/web/src/hooks/queries/notificationWebhooks.ts b/web/src/hooks/queries/notificationWebhooks.ts new file mode 100644 index 00000000..ec559919 --- /dev/null +++ b/web/src/hooks/queries/notificationWebhooks.ts @@ -0,0 +1,121 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/api/client"; +import type { + NotificationCapability, + NotificationWebhook, + NotificationWebhookInput, + NotificationWebhookTestResult, + WebPushSubscriptionView, +} from "@/api/types"; +import { notificationKeys } from "./keys"; +import { toast } from "sonner"; + +export function useNotificationCapability() { + return useQuery({ + queryKey: notificationKeys.capability(), + queryFn: () => api("/notifications/capability"), + staleTime: 5 * 60_000, + }); +} + +export function useNotificationWebhooks(enabled = true) { + return useQuery({ + queryKey: notificationKeys.webhooks(), + queryFn: () => + api<{ webhooks: NotificationWebhook[] }>("/notifications/webhooks").then( + (d) => d.webhooks ?? [], + ), + enabled, + }); +} + +export function useCreateNotificationWebhook() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: NotificationWebhookInput) => + api("/notifications/webhooks", { + method: "POST", + body: JSON.stringify(input), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: notificationKeys.webhooks() }); + }, + }); +} + +export function useUpdateNotificationWebhook() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...input }: NotificationWebhookInput & { id: string }) => + api(`/notifications/webhooks/${id}`, { + method: "PUT", + body: JSON.stringify(input), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: notificationKeys.webhooks() }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to update webhook"); + }, + }); +} + +export function useDeleteNotificationWebhook() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api(`/notifications/webhooks/${id}`, { method: "DELETE" }), + onSuccess: () => { + toast.success("Webhook deleted"); + void queryClient.invalidateQueries({ queryKey: notificationKeys.webhooks() }); + }, + onError: () => { + toast.error("Failed to delete webhook"); + }, + }); +} + +export function useTestNotificationWebhook() { + return useMutation({ + mutationFn: (id: string) => + api(`/notifications/webhooks/${id}/test`, { + method: "POST", + }), + }); +} + +export function useRotateNotificationWebhookSecret() { + return useMutation({ + mutationFn: (id: string) => + api<{ signing_secret: string }>(`/notifications/webhooks/${id}/rotate-secret`, { + method: "POST", + }), + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to rotate signing secret"); + }, + }); +} + +export function useWebPushSubscriptions(enabled = true) { + return useQuery({ + queryKey: notificationKeys.webPushSubscriptions(), + queryFn: () => + api<{ subscriptions: WebPushSubscriptionView[] }>( + "/notifications/web-push/subscriptions", + ).then((d) => d.subscriptions ?? []), + enabled, + }); +} + +export function useDeleteWebPushSubscription() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + api(`/notifications/web-push/subscriptions/${id}`, { method: "DELETE" }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: notificationKeys.webPushSubscriptions() }); + }, + onError: () => { + toast.error("Failed to remove push subscription"); + }, + }); +} diff --git a/web/src/hooks/queries/notifications.ts b/web/src/hooks/queries/notifications.ts new file mode 100644 index 00000000..88dbdb0a --- /dev/null +++ b/web/src/hooks/queries/notifications.ts @@ -0,0 +1,205 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/api/client"; +import type { + AppNotification, + NotificationListResponse, + NotificationPreferences, + NotificationReadEventPayload, + NotificationUnreadCountResponse, +} from "@/api/types"; +import { notificationKeys } from "./keys"; +import { toast } from "sonner"; + +const NOTIFICATIONS_PAGE_SIZE = 25; + +export function useNotifications(status: "all" | "unread" = "all") { + return useInfiniteQuery({ + queryKey: notificationKeys.list(status), + initialPageParam: "", + queryFn: ({ pageParam }) => { + const search = new URLSearchParams({ limit: String(NOTIFICATIONS_PAGE_SIZE) }); + if (status === "unread") { + search.set("status", "unread"); + } + if (pageParam) { + search.set("before", pageParam); + } + return api(`/notifications?${search.toString()}`); + }, + getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, + }); +} + +export function useUnreadNotificationCount(enabled = true) { + return useQuery({ + queryKey: notificationKeys.unreadCount(), + queryFn: () => + api("/notifications/unread-count").then((d) => d.count), + enabled, + staleTime: 30_000, + }); +} + +export function useMarkNotificationRead() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api(`/notifications/${id}/read`, { method: "POST" }), + onMutate: (id: string) => { + applyNotificationRead(queryClient, { profile_id: "", id }); + }, + onError: () => { + toast.error("Failed to mark notification read"); + void queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + }, + }); +} + +export function useMarkAllNotificationsRead() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => api("/notifications/read-all", { method: "POST" }), + onMutate: () => { + applyNotificationRead(queryClient, { profile_id: "", all: true }); + }, + onError: () => { + toast.error("Failed to mark notifications read"); + void queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + }, + }); +} + +export function useNotificationPreferences() { + return useQuery({ + queryKey: notificationKeys.preferences(), + queryFn: () => api("/notifications/preferences"), + }); +} + +export function useUpdateNotificationPreferences() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (update: Partial) => + api("/notifications/preferences", { + method: "PUT", + body: JSON.stringify(update), + }), + onSuccess: (prefs) => { + queryClient.setQueryData(notificationKeys.preferences(), prefs); + }, + onError: () => { + toast.error("Failed to save notification preferences"); + }, + }); +} + +// --- Realtime cache reducers (used by RealtimeEventsProvider) --- + +type NotificationsInfiniteData = { + pages: NotificationListResponse[]; + pageParams: unknown[]; +}; + +function updateCachedLists( + queryClient: QueryClient, + update: (notification: AppNotification) => AppNotification, +) { + for (const status of ["all", "unread"] as const) { + queryClient.setQueryData(notificationKeys.list(status), (data) => + data + ? { + ...data, + pages: data.pages.map((page) => ({ + ...page, + notifications: page.notifications.map(update), + })), + } + : data, + ); + } +} + +/** Prepends a freshly created notification and bumps the unread badge. */ +export function applyNotificationCreated(queryClient: QueryClient, notification: AppNotification) { + queryClient.setQueryData(notificationKeys.list("all"), (data) => { + const first = data?.pages[0]; + if (!data || !first) { + return data; + } + if (first.notifications.some((entry) => entry.id === notification.id)) { + return data; + } + return { + ...data, + pages: [ + { ...first, notifications: [notification, ...first.notifications] }, + ...data.pages.slice(1), + ], + }; + }); + void queryClient.invalidateQueries({ queryKey: notificationKeys.list("unread") }); + if (!notification.read_at) { + queryClient.setQueryData(notificationKeys.unreadCount(), (count) => (count ?? 0) + 1); + } +} + +/** Applies a read event (single id or all) to cached rows and the badge. */ +export function applyNotificationRead( + queryClient: QueryClient, + payload: NotificationReadEventPayload, +) { + const readAt = new Date().toISOString(); + if (payload.all) { + updateCachedLists(queryClient, (entry) => + entry.read_at ? entry : { ...entry, read_at: readAt }, + ); + queryClient.setQueryData(notificationKeys.unreadCount(), 0); + return; + } + if (!payload.id) { + return; + } + let found = false; + let transitioned = false; + updateCachedLists(queryClient, (entry) => { + if (entry.id !== payload.id) { + return entry; + } + found = true; + if (entry.read_at) { + return entry; + } + transitioned = true; + return { ...entry, read_at: readAt }; + }); + // Decrement when we observed the unread -> read flip, or when the row is + // not cached at all (the backend only publishes read events on real + // transitions, so an unseen row was unread). + if (!found || transitioned) { + queryClient.setQueryData(notificationKeys.unreadCount(), (count) => + count == null ? count : Math.max(0, count - 1), + ); + } +} + +/** Hydrates the unread badge from the websocket snapshot (recent unread rows). */ +export function applyNotificationsSnapshot(queryClient: QueryClient, rows: AppNotification[]) { + // The snapshot is capped (25 rows); use it as a lower bound and refresh the + // real count lazily. + queryClient.setQueryData(notificationKeys.unreadCount(), (count) => + Math.max(count ?? 0, rows.length), + ); + void queryClient.invalidateQueries({ queryKey: notificationKeys.unreadCount() }); + void queryClient.invalidateQueries({ + queryKey: notificationKeys.all, + refetchType: "active", + }); +} + +/** Formats the "S2E5" style episode code for a notification row. */ +export function formatEpisodeCode(notification: AppNotification): string | null { + if (notification.season_number == null || notification.episode_number == null) { + return null; + } + return `S${notification.season_number}E${notification.episode_number}`; +} diff --git a/web/src/lib/webPush.ts b/web/src/lib/webPush.ts new file mode 100644 index 00000000..ce7b484d --- /dev/null +++ b/web/src/lib/webPush.ts @@ -0,0 +1,124 @@ +import { api } from "@/api/client"; + +/** + * Browser-side Web Push subscription helpers. The server's VAPID public key + * comes from the notifications capability endpoint; subscriptions are + * profile-scoped server-side. + */ + +export type WebPushSupport = "supported" | "unsupported" | "denied"; + +export function webPushSupport(): WebPushSupport { + if ( + !("serviceWorker" in navigator) || + !("PushManager" in window) || + !("Notification" in window) + ) { + return "unsupported"; + } + if (Notification.permission === "denied") { + return "denied"; + } + return "supported"; +} + +function urlBase64ToUint8Array(base64: string): Uint8Array { + const padding = "=".repeat((4 - (base64.length % 4)) % 4); + const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = window.atob(normalized); + const output = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) { + output[i] = raw.charCodeAt(i); + } + return output; +} + +async function pushRegistration(): Promise { + const registration = await navigator.serviceWorker.register("/sw.js"); + await navigator.serviceWorker.ready; + return registration; +} + +/** Returns the browser's current push subscription, if any. */ +export async function currentWebPushSubscription(): Promise { + if (webPushSupport() === "unsupported") { + return null; + } + try { + const registration = await navigator.serviceWorker.getRegistration("/sw.js"); + return (await registration?.pushManager.getSubscription()) ?? null; + } catch { + return null; + } +} + +function describeDevice(): string { + const ua = navigator.userAgent; + const browser = /firefox/i.test(ua) + ? "Firefox" + : /edg\//i.test(ua) + ? "Edge" + : /chrome|chromium/i.test(ua) + ? "Chrome" + : /safari/i.test(ua) + ? "Safari" + : "Browser"; + const platform = /windows/i.test(ua) + ? "Windows" + : /mac os/i.test(ua) + ? "macOS" + : /android/i.test(ua) + ? "Android" + : /iphone|ipad/i.test(ua) + ? "iOS" + : /linux/i.test(ua) + ? "Linux" + : ""; + return platform ? `${browser} on ${platform}` : browser; +} + +/** + * Requests permission, subscribes this browser, and registers the + * subscription with the server for the active profile. Throws with a + * user-presentable message on failure. + */ +export async function enableWebPush(vapidPublicKey: string): Promise { + if (webPushSupport() === "unsupported") { + throw new Error("This browser does not support push notifications"); + } + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + throw new Error("Notification permission was not granted"); + } + const registration = await pushRegistration(); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource, + }); + const json = subscription.toJSON(); + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { + throw new Error("The browser returned an incomplete push subscription"); + } + await api("/notifications/web-push/subscriptions", { + method: "POST", + body: JSON.stringify({ + endpoint: json.endpoint, + keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }, + device_name: describeDevice(), + }), + }); +} + +/** Unsubscribes this browser and removes the server-side registration. */ +export async function disableWebPush(): Promise { + const subscription = await currentWebPushSubscription(); + if (!subscription) { + return; + } + const endpoint = subscription.endpoint; + await subscription.unsubscribe(); + await api("/notifications/web-push/unsubscribe", { + method: "POST", + body: JSON.stringify({ endpoint }), + }); +} diff --git a/web/src/pages/Notifications.tsx b/web/src/pages/Notifications.tsx new file mode 100644 index 00000000..4ccee7c5 --- /dev/null +++ b/web/src/pages/Notifications.tsx @@ -0,0 +1,362 @@ +import { useState } from "react"; +import { Link } from "react-router"; +import { Bell, BellOff, Check, CheckCheck, Loader2, Settings2 } from "lucide-react"; +import type { AppNotification } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + formatEpisodeCode, + useMarkAllNotificationsRead, + useMarkNotificationRead, + useNotificationPreferences, + useNotifications, + useUnreadNotificationCount, + useUpdateNotificationPreferences, +} from "@/hooks/queries/notifications"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { decodeThumbhash } from "@/lib/thumbhash"; + +function formatNotificationTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ""; + } + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.round(diffMs / 60_000); + if (diffMinutes < 1) { + return "Just now"; + } + if (diffMinutes < 60) { + return `${diffMinutes}m ago`; + } + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) { + return `${diffHours}h ago`; + } + const diffDays = Math.round(diffHours / 24); + if (diffDays < 7) { + return `${diffDays}d ago`; + } + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function notificationTitle(notification: AppNotification): string { + if (notification.type === "episode.available") { + return notification.series_title || "New episode available"; + } + // Unknown types render with a generic fallback by design — the type + // registry is extensible. + return "Notification"; +} + +function notificationDescription(notification: AppNotification): string { + if (notification.type === "episode.available") { + const code = formatEpisodeCode(notification); + return ( + [code, notification.episode_title].filter(Boolean).join(" — ") || "New episode available" + ); + } + return notification.type; +} + +function reasonLabels(notification: AppNotification): string[] { + const flags = notification.reason_flags ?? {}; + const labels: string[] = []; + if (flags.favorite) { + labels.push("Favorite"); + } + if (flags.watchlist) { + labels.push("Watchlist"); + } + if (flags.continue_watching) { + labels.push("Continue Watching"); + } + if (flags.next_up) { + labels.push("Next Up"); + } + return labels; +} + +function NotificationRow({ + notification, + onMarkRead, +}: { + notification: AppNotification; + onMarkRead: (id: string) => void; +}) { + const unread = !notification.read_at; + const thumbhashUrl = notification.poster_thumbhash + ? decodeThumbhash(notification.poster_thumbhash) + : ""; + const detailHref = notification.episode_id + ? `/item/${notification.episode_id}` + : notification.series_id + ? `/item/${notification.series_id}` + : null; + + const body = ( + <> +
    + {notification.poster_url && ( + + )} +
    +
    +
    + {unread && ( + + )} + + {notificationTitle(notification)} + + + {formatNotificationTime(notification.created_at)} + +
    +
    + {notificationDescription(notification)} +
    + {reasonLabels(notification).length > 0 && ( +
    + {reasonLabels(notification).map((label) => ( + + {label} + + ))} +
    + )} +
    + + ); + + return ( +
  • + {detailHref ? ( + unread && onMarkRead(notification.id)} + className={`hover:bg-muted/60 flex items-start gap-3 rounded-xl px-3 py-3 transition-colors ${ + unread ? "bg-muted/30" : "" + }`} + > + {body} + + ) : ( +
    + {body} +
    + )} + {unread && ( + + )} +
  • + ); +} + +function NotificationPreferencesPopover() { + const { data: prefs, isLoading } = useNotificationPreferences(); + const updatePrefs = useUpdateNotificationPreferences(); + + const toggles: Array<{ + key: + | "enabled" + | "notify_favorites" + | "notify_watchlist" + | "notify_continue_watching" + | "notify_next_up"; + label: string; + description: string; + }> = [ + { + key: "enabled", + label: "Notifications", + description: "Master switch for this profile", + }, + { + key: "notify_favorites", + label: "Favorites", + description: "New episodes of favorited series", + }, + { + key: "notify_watchlist", + label: "Watchlist", + description: "New episodes of watchlisted series", + }, + { + key: "notify_continue_watching", + label: "Continue Watching", + description: "Series you are actively watching", + }, + { + key: "notify_next_up", + label: "Next Up", + description: "The next episode after your progress", + }, + ]; + + return ( + + + + + + {isLoading || !prefs ? ( +
    + + + +
    + ) : ( +
    + {toggles.map((toggle, index) => ( +
    +
    +
    {toggle.label}
    +
    {toggle.description}
    +
    + 0 && !prefs.enabled} + onCheckedChange={(checked) => updatePrefs.mutate({ [toggle.key]: checked })} + /> +
    + ))} +
    + )} +
    +
    + ); +} + +export default function Notifications() { + useDocumentTitle("Notifications"); + const [statusFilter, setStatusFilter] = useState<"all" | "unread">("all"); + const list = useNotifications(statusFilter); + const { data: unreadCount } = useUnreadNotificationCount(); + const markRead = useMarkNotificationRead(); + const markAllRead = useMarkAllNotificationsRead(); + + const notifications = list.data?.pages.flatMap((page) => page.notifications) ?? []; + + return ( +
    +
    +

    + + Notifications +

    +
    + {(unreadCount ?? 0) > 0 && ( + + )} + +
    +
    + +
    + {(["all", "unread"] as const).map((status) => ( + + ))} +
    + + {list.isLoading ? ( +
    + {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
    + ) : notifications.length === 0 ? ( +
    + +
    + {statusFilter === "unread" ? "No unread notifications" : "No notifications yet"} +
    +
    + You will be notified here when new episodes arrive for series you favorite, watchlist, + or are watching. +
    +
    + ) : ( + <> +
      + {notifications.map((notification) => ( + markRead.mutate(id)} + /> + ))} +
    + {list.hasNextPage && ( +
    + +
    + )} + + )} +
    + ); +} diff --git a/web/src/pages/SettingsLayout.tsx b/web/src/pages/SettingsLayout.tsx index 4dad866c..f7222ffd 100644 --- a/web/src/pages/SettingsLayout.tsx +++ b/web/src/pages/SettingsLayout.tsx @@ -13,6 +13,7 @@ import { Users, Server, Sparkles, + Bell, } from "lucide-react"; // Sparkles is used by the Personalization nav entry below. import type { LucideIcon } from "lucide-react"; @@ -128,6 +129,12 @@ const NAV_SECTIONS: NavSection[] = [ { label: "Account", items: [ + { + path: "notifications", + label: "Notifications", + icon: Bell, + description: "New-episode alerts and webhooks", + }, { path: "profiles", label: "Profiles", diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx index 57ac5bb0..9901a3f5 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx @@ -16,12 +16,14 @@ import { Layers, Subtitles, Sparkles, + Mail, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { SideNavItem, SideNavSection } from "@/components/SideNav"; import { cn } from "@/lib/utils"; +import EmailSettings from "./EmailSettings"; import GeneralSettings from "./GeneralSettings"; import PlaybackSettings from "./PlaybackSettings"; import ScannerSettings from "./ScannerSettings"; @@ -83,6 +85,7 @@ const SETTINGS_GROUPS: SettingsNavGroup[] = [ component: WatchProvidersSettings, }, { id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings }, + { id: "email", label: "Email", icon: Mail, component: EmailSettings }, { id: "compatibility-proxies", label: "Compatibility Proxies", diff --git a/web/src/pages/admin-settings/EmailSettings.tsx b/web/src/pages/admin-settings/EmailSettings.tsx new file mode 100644 index 00000000..33063481 --- /dev/null +++ b/web/src/pages/admin-settings/EmailSettings.tsx @@ -0,0 +1,182 @@ +import { useMemo, useState } from "react"; +import { Loader2, Send } from "lucide-react"; +import { toast } from "sonner"; +import { api } from "@/api/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useSettingsForm } from "@/hooks/useSettingsForm"; +import { FieldGroup } from "./FieldGroup"; +import { SaveBar } from "./SaveBar"; +import { SettingField } from "./SettingField"; + +const KEYS = [ + "email.enabled", + "email.smtp_host", + "email.smtp_port", + "email.smtp_security", + "email.smtp_username", + "email.smtp_password", + "email.from_address", + "email.from_name", +]; + +interface EmailTestResult { + ok: boolean; + duration_ms: number; + message?: string; +} + +function TestEmailRow() { + const [recipient, setRecipient] = useState(""); + const [pending, setPending] = useState(false); + const [result, setResult] = useState(null); + + const sendTest = async () => { + setPending(true); + setResult(null); + try { + const response = await api("/admin/email/test", { + method: "POST", + body: JSON.stringify({ to: recipient.trim() }), + }); + setResult(response); + if (response.ok) { + toast.success("Test email sent"); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Test request failed"); + } finally { + setPending(false); + } + }; + + return ( +
    +
    + setRecipient(event.target.value)} + /> + +
    + {result && ( +

    + {result.ok + ? `Delivered to the SMTP server in ${result.duration_ms}ms.` + : result.message || "Test failed."} +

    + )} +

    + Save your changes before testing — the test uses the stored settings. +

    +
    + ); +} + +export default function EmailSettings() { + const form = useSettingsForm({ keys: useMemo(() => KEYS, []) }); + + if (form.isLoading) return
    Loading...
    ; + + return ( +
    +
    +

    Email

    +

    + Outbound email via your own SMTP server. Used by features that send mail — notification + emails, account flows — once they are enabled. +

    +
    + +
    + + form.setValue("email.enabled", v)} + /> + form.setValue("email.from_address", v)} + /> + form.setValue("email.from_name", v)} + /> + + + + form.setValue("email.smtp_host", v)} + /> + form.setValue("email.smtp_port", v)} + /> + form.setValue("email.smtp_security", v)} + /> + form.setValue("email.smtp_username", v)} + /> + form.setValue("email.smtp_password", v)} + /> + + + + + +
    + + +
    + ); +} diff --git a/web/src/pages/settings/NotificationsSettings.tsx b/web/src/pages/settings/NotificationsSettings.tsx new file mode 100644 index 00000000..9596dfd7 --- /dev/null +++ b/web/src/pages/settings/NotificationsSettings.tsx @@ -0,0 +1,656 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + BellRing, + Check, + Copy, + KeyRound, + Loader2, + MonitorSmartphone, + Pencil, + Plus, + Send, + Trash2, + Webhook as WebhookIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import type { + NotificationPreferences, + NotificationWebhook, + NotificationWebhookInput, + NotificationWebhookTestResult, +} from "@/api/types"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { SettingsGroup } from "@/components/settings/SettingsGroup"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + useNotificationPreferences, + useUpdateNotificationPreferences, +} from "@/hooks/queries/notifications"; +import { + useCreateNotificationWebhook, + useDeleteNotificationWebhook, + useDeleteWebPushSubscription, + useNotificationCapability, + useNotificationWebhooks, + useRotateNotificationWebhookSecret, + useTestNotificationWebhook, + useUpdateNotificationWebhook, + useWebPushSubscriptions, +} from "@/hooks/queries/notificationWebhooks"; +import { notificationKeys } from "@/hooks/queries/keys"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { + currentWebPushSubscription, + disableWebPush, + enableWebPush, + webPushSupport, +} from "@/lib/webPush"; + +const REASON_FIELDS = [ + { key: "notify_favorites", label: "Favorites" }, + { key: "notify_watchlist", label: "Watchlist" }, + { key: "notify_continue_watching", label: "Continue Watching" }, + { key: "notify_next_up", label: "Next Up" }, +] as const; + +type ReasonKey = (typeof REASON_FIELDS)[number]["key"]; + +function formatRelativeTime(value: string | null): string | null { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + const diffMinutes = Math.round((Date.now() - date.getTime()) / 60_000); + if (diffMinutes < 1) { + return "just now"; + } + if (diffMinutes < 60) { + return `${diffMinutes}m ago`; + } + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) { + return `${diffHours}h ago`; + } + return `${Math.round(diffHours / 24)}d ago`; +} + +function PreferencesSection() { + const { data: prefs, isLoading } = useNotificationPreferences(); + const updatePrefs = useUpdateNotificationPreferences(); + + if (isLoading || !prefs) { + return ( + + + + ); + } + + return ( + +
    +
    +
    Enable notifications
    +
    Master switch for this profile
    +
    + updatePrefs.mutate({ enabled: checked })} + /> +
    + {REASON_FIELDS.map((field) => ( +
    +
    {field.label}
    + updatePrefs.mutate({ [field.key]: checked })} + /> +
    + ))} +
    + ); +} + +function WebPushSection() { + const queryClient = useQueryClient(); + const capability = useNotificationCapability(); + const webPushCap = capability.data?.web_push; + const available = webPushCap?.available ?? false; + const { data: subscriptions } = useWebPushSubscriptions(available); + const removeSubscription = useDeleteWebPushSubscription(); + const support = webPushSupport(); + const [thisEndpoint, setThisEndpoint] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void currentWebPushSubscription().then((sub) => setThisEndpoint(sub?.endpoint ?? null)); + }, []); + + const subscribedHere = + thisEndpoint != null && (subscriptions ?? []).some((sub) => sub.endpoint === thisEndpoint); + + const enable = async () => { + if (!webPushCap?.public_key) { + toast.error("Web push is not available on this server"); + return; + } + setBusy(true); + try { + await enableWebPush(webPushCap.public_key); + const sub = await currentWebPushSubscription(); + setThisEndpoint(sub?.endpoint ?? null); + toast.success("Browser notifications enabled"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to enable notifications"); + } finally { + setBusy(false); + void queryClient.invalidateQueries({ queryKey: notificationKeys.webPushSubscriptions() }); + } + }; + + const disable = async () => { + setBusy(true); + try { + await disableWebPush(); + setThisEndpoint(null); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to disable notifications"); + } finally { + setBusy(false); + void queryClient.invalidateQueries({ queryKey: notificationKeys.webPushSubscriptions() }); + } + }; + + if (!available) { + return null; + } + + const otherSubscriptions = (subscriptions ?? []).filter((sub) => sub.endpoint !== thisEndpoint); + + return ( + + {support === "unsupported" ? ( +
    + This browser does not support push notifications. +
    + ) : support === "denied" && !subscribedHere ? ( +
    + Notifications are blocked for this site. Allow them in your browser's site settings, then + return here. +
    + ) : ( +
    +
    + +
    +
    This browser
    +
    + {subscribedHere ? "Receiving notifications" : "Not receiving notifications"} +
    +
    +
    + +
    + )} + + {otherSubscriptions.length > 0 && ( +
    +
    Other devices
    + {otherSubscriptions.map((sub) => ( +
    +
    + + {sub.device_name || "Unknown device"} +
    + +
    + ))} +
    + )} +
    + ); +} + +function SigningSecretDialog({ secret, onClose }: { secret: string | null; onClose: () => void }) { + const [copied, setCopied] = useState(false); + return ( + !open && onClose()}> + + + Save your signing secret + + Silo signs every delivery with this secret so your receiver can verify it. It is shown + only once — store it on the receiving service now. You can rotate it later if it is + lost. + + +
    + {secret} + +
    + + + +
    +
    + ); +} + +function WebhookFormDialog({ + open, + onOpenChange, + webhook, + globalPrefs, + onSecret, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + webhook: NotificationWebhook | null; + globalPrefs: NotificationPreferences | undefined; + onSecret: (secret: string) => void; +}) { + const create = useCreateNotificationWebhook(); + const update = useUpdateNotificationWebhook(); + const [name, setName] = useState(webhook?.name ?? ""); + const [url, setUrl] = useState(""); + const [reasons, setReasons] = useState>({ + notify_favorites: webhook?.notify_favorites ?? true, + notify_watchlist: webhook?.notify_watchlist ?? true, + notify_continue_watching: webhook?.notify_continue_watching ?? true, + notify_next_up: webhook?.notify_next_up ?? true, + }); + const pending = create.isPending || update.isPending; + const editing = webhook != null; + + const submit = () => { + const input: NotificationWebhookInput = { name: name.trim(), ...reasons }; + if (url.trim()) { + input.url = url.trim(); + } + if (editing) { + update.mutate( + { id: webhook.id, ...input }, + { + onSuccess: () => onOpenChange(false), + }, + ); + return; + } + if (!input.url) { + toast.error("A webhook URL is required"); + return; + } + create.mutate(input, { + onSuccess: (created) => { + onOpenChange(false); + toast.success(`Webhook "${created.name}" created`); + if (created.signing_secret) { + onSecret(created.signing_secret); + } + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to create webhook"); + }, + }); + }; + + return ( + + + + {editing ? `Edit "${webhook.name}"` : "Add webhook"} + + Discord webhook URLs render as native embeds. Any other HTTPS endpoint receives signed + JSON. + + +
    +
    + + setName(event.target.value)} + /> +
    +
    + + setUrl(event.target.value)} + /> +
    +
    + + {REASON_FIELDS.map((field) => { + const globallyDisabled = + globalPrefs != null && + (!globalPrefs.enabled || + !(globalPrefs[field.key as keyof NotificationPreferences] as boolean)); + return ( +
    +
    + {field.label} + {globallyDisabled && ( + + disabled in profile preferences + + )} +
    + + setReasons((current) => ({ ...current, [field.key]: checked })) + } + /> +
    + ); + })} +
    +
    + + + + +
    +
    + ); +} + +function WebhookCard({ + webhook, + onEdit, + onSecret, +}: { + webhook: NotificationWebhook; + onEdit: () => void; + onSecret: (secret: string) => void; +}) { + const update = useUpdateNotificationWebhook(); + const remove = useDeleteNotificationWebhook(); + const test = useTestNotificationWebhook(); + const rotate = useRotateNotificationWebhookSecret(); + const [confirmDelete, setConfirmDelete] = useState(false); + const [testResult, setTestResult] = useState(null); + + const lastSuccess = formatRelativeTime(webhook.last_success_at); + const lastFailure = formatRelativeTime(webhook.last_failure_at); + const failing = + webhook.last_failure_at != null && + (webhook.last_success_at == null || webhook.last_failure_at > webhook.last_success_at); + const enabledReasons = REASON_FIELDS.filter( + (field) => webhook[field.key as keyof NotificationWebhook] as boolean, + ).map((field) => field.label); + + return ( +
    +
    + {webhook.name} + {webhook.type} + {webhook.url_host} +
    + + {webhook.enabled ? "Enabled" : "Disabled"} + + update.mutate({ id: webhook.id, enabled: checked })} + /> +
    +
    + +
    + {enabledReasons.length === REASON_FIELDS.length + ? "All reasons" + : enabledReasons.length > 0 + ? enabledReasons.join(" · ") + : "No reasons selected"} +
    + + {lastSuccess && !failing && ( +
    Last success: {lastSuccess}
    + )} + {failing && ( +
    + + + {webhook.disabled_reason + ? `Disabled: ${webhook.disabled_reason}` + : `Last failure${lastFailure ? ` ${lastFailure}` : ""}: ${ + webhook.last_failure_message || `HTTP ${webhook.last_failure_status ?? "error"}` + }. Check the destination URL.`} + +
    + )} + {testResult && ( +
    + Test {testResult.ok ? "succeeded" : "failed"} + {testResult.http_status ? ` (HTTP ${testResult.http_status}` : " ("} + {`${testResult.duration_ms}ms)`} + {testResult.message ? ` — ${testResult.message}` : ""} +
    + )} + +
    + + + {webhook.type === "generic" && ( + + )} + +
    + + remove.mutate(webhook.id, { onSettled: () => setConfirmDelete(false) })} + /> + {/* The edit dialog is hosted by the parent so state resets per webhook. */} + {update.isPending && Saving…} +
    + ); +} + +export default function NotificationsSettings() { + useDocumentTitle("Notification Settings"); + const capability = useNotificationCapability(); + const webhooksAvailable = capability.data?.webhooks.available ?? false; + const { data: webhooks, isLoading } = useNotificationWebhooks(webhooksAvailable); + const { data: globalPrefs } = useNotificationPreferences(); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [secret, setSecret] = useState(null); + + const maxPerProfile = capability.data?.webhooks.max_per_profile ?? 10; + const atLimit = (webhooks?.length ?? 0) >= maxPerProfile; + + return ( +
    + + + + + + {!webhooksAvailable ? ( +
    + Webhooks are not available on this server. +
    + ) : isLoading ? ( + + ) : ( + <> + {(webhooks ?? []).map((webhook) => ( + { + setEditing(webhook); + setFormOpen(true); + }} + /> + ))} + {(webhooks ?? []).length === 0 && ( +
    + + No webhooks yet. +
    + )} +
    + + {atLimit && ( + + Limit of {maxPerProfile} webhooks reached + + )} +
    + + )} +
    + + {formOpen && ( + { + setFormOpen(open); + if (!open) { + setEditing(null); + } + }} + webhook={editing} + globalPrefs={globalPrefs} + onSecret={setSecret} + /> + )} + setSecret(null)} /> +
    + ); +} From 4fd21c7aee609adb20c34953d80cad935f8207a4 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:14:54 -0400 Subject: [PATCH 03/19] feat(web): admin settings page for notification controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose every live notifications.* server setting on a new admin Notifications tab (next to Email): kill switches, fanout tuning, webhook guards, and retention. Unset kill switches render as enabled to match the backend defaults, numeric fields surface their effective defaults, and enabling private webhook destinations shows an SSRF warning. v1.5 roadmap item 1; no backend changes — all keys are live-read. Co-Authored-By: Claude Fable 5 --- .../AdminSettingsLayout.test.tsx | 1 + .../admin-settings/AdminSettingsLayout.tsx | 8 + .../NotificationsAdminSettings.tsx | 182 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 web/src/pages/admin-settings/NotificationsAdminSettings.tsx diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx index 5c88a0e9..461e4a07 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx @@ -41,6 +41,7 @@ describe("AdminSettingsLayout", () => { "Downloads", "Watch Providers", "Integrations", + "Notifications", "Compatibility Proxies", "Rate Limiting", "Database", diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx index 9901a3f5..2ff14308 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx @@ -17,6 +17,7 @@ import { Subtitles, Sparkles, Mail, + Bell, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; @@ -24,6 +25,7 @@ import { SideNavItem, SideNavSection } from "@/components/SideNav"; import { cn } from "@/lib/utils"; import EmailSettings from "./EmailSettings"; +import NotificationsAdminSettings from "./NotificationsAdminSettings"; import GeneralSettings from "./GeneralSettings"; import PlaybackSettings from "./PlaybackSettings"; import ScannerSettings from "./ScannerSettings"; @@ -86,6 +88,12 @@ const SETTINGS_GROUPS: SettingsNavGroup[] = [ }, { id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings }, { id: "email", label: "Email", icon: Mail, component: EmailSettings }, + { + id: "notifications", + label: "Notifications", + icon: Bell, + component: NotificationsAdminSettings, + }, { id: "compatibility-proxies", label: "Compatibility Proxies", diff --git a/web/src/pages/admin-settings/NotificationsAdminSettings.tsx b/web/src/pages/admin-settings/NotificationsAdminSettings.tsx new file mode 100644 index 00000000..42752e47 --- /dev/null +++ b/web/src/pages/admin-settings/NotificationsAdminSettings.tsx @@ -0,0 +1,182 @@ +import { useMemo } from "react"; +import { TriangleAlert } from "lucide-react"; +import { useSettingsForm } from "@/hooks/useSettingsForm"; +import { FieldGroup } from "./FieldGroup"; +import { SaveBar } from "./SaveBar"; +import { SettingField } from "./SettingField"; + +const KEYS = [ + "notifications.release_events_enabled", + "notifications.fanout_enabled", + "notifications.ui_enabled", + "notifications.webhooks_enabled", + "notifications.web_push_enabled", + "notifications.fanout.settle_seconds", + "notifications.fanout.max_series_burst", + "notifications.fanout.max_event_age_hours", + "notifications.webhooks.max_per_profile", + "notifications.webhooks.allow_private_destinations", + "notifications.webhooks.deliveries_per_minute_per_profile", + "notifications.retention.read_days", + "notifications.retention.unread_days", + "notifications.retention.event_days", +]; + +export default function NotificationsAdminSettings() { + const form = useSettingsForm({ keys: useMemo(() => KEYS, []) }); + + if (form.isLoading) return
    Loading...
    ; + + // Kill switches default to enabled when unset; the backend treats any + // unrecognized value as the default, so an empty stored value means "on". + const toggleValue = (key: string) => form.getValue(key) || "true"; + // Numeric settings fall back to their server-side defaults when unset; + // surface the effective default instead of an empty input. + const numberValue = (key: string, fallback: string) => form.getValue(key) || fallback; + + const allowPrivate = + form.getValue("notifications.webhooks.allow_private_destinations") === "true"; + + return ( +
    +
    +

    Notifications

    +

    + Operational controls for the notification system. All settings apply live — no restart + needed. Per-profile preferences live in each user's own notification settings. +

    +
    + +
    + + form.setValue("notifications.release_events_enabled", v)} + /> + form.setValue("notifications.fanout_enabled", v)} + /> + form.setValue("notifications.ui_enabled", v)} + /> + form.setValue("notifications.webhooks_enabled", v)} + /> + form.setValue("notifications.web_push_enabled", v)} + /> + + + + form.setValue("notifications.fanout.settle_seconds", v)} + /> + form.setValue("notifications.fanout.max_series_burst", v)} + /> + form.setValue("notifications.fanout.max_event_age_hours", v)} + /> + + + + form.setValue("notifications.webhooks.max_per_profile", v)} + /> + + form.setValue("notifications.webhooks.deliveries_per_minute_per_profile", v) + } + /> + form.setValue("notifications.webhooks.allow_private_destinations", v)} + /> + {allowPrivate && ( +
    + +

    + Private destinations are allowed: any user with webhook access can make this server + send requests to internal network addresses. Leave this off outside development. +

    +
    + )} +
    + + + form.setValue("notifications.retention.read_days", v)} + /> + form.setValue("notifications.retention.unread_days", v)} + /> + form.setValue("notifications.retention.event_days", v)} + /> + +
    + + +
    + ); +} From d9e27da59e06a4b4209cb278989d34035c50c34d Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:04:22 -0400 Subject: [PATCH 04/19] feat(notifications): request-fulfilled notifications across all channels Notify the requesting profile once its media request is actually present in the catalog (roadmap 06, item 2). Completion transitions stay notification-agnostic; a presence-gated pass at the end of each reconcile run fires the notice, so it means "watchable in Silo", not "download finished". - New System.DispatchOperational: delivery insert + webhook/web-push outbox enqueue in one transaction, post-commit multi-dispatch. The webhook auto-disable notice now rides the same path (replacing its hand-rolled hub publish and the now-removed InsertOperational), which also delivers auto-disable notices over web push. - At-most-once delivery: partial unique index on (profile_id, reason_flags->>'request_id') plus a fulfilled_notified_at marker on media_requests, backfilled for pre-existing completed requests so deploys never flood. - Per-webhook notify_requests toggle (default on) through repo, service, API, and settings UI; gated independently of the episode reason flags. - request.fulfilled rendering in web inbox, realtime toast, web push payload, and Discord/generic webhook payloads, deep-linking to the matched catalog item. Co-Authored-By: Claude Fable 5 --- cmd/silo/main.go | 3 + .../api/handlers/notifications_webhooks.go | 4 + internal/notifications/delivery_repo.go | 22 ---- .../notifications/operational_dispatch.go | 99 +++++++++++++++++ internal/notifications/request_notifier.go | 90 ++++++++++++++++ internal/notifications/system.go | 12 ++- internal/notifications/webhook_logic_test.go | 79 ++++++++++++++ .../notifications/webhook_payload_discord.go | 31 +++++- .../notifications/webhook_payload_generic.go | 17 +++ internal/notifications/webhook_repo.go | 14 ++- internal/notifications/webhook_sender.go | 27 ++--- internal/notifications/webhook_service.go | 5 + internal/notifications/webhook_types.go | 1 + internal/notifications/webpush_logic_test.go | 20 ++++ internal/notifications/webpush_sender.go | 11 ++ internal/requests/notify.go | 66 ++++++++++++ internal/requests/notify_test.go | 101 ++++++++++++++++++ internal/requests/repository.go | 47 ++++++++ internal/requests/service.go | 7 ++ internal/requests/service_test.go | 29 +++++ internal/requests/store.go | 6 ++ ...193802_request_fulfilled_notifications.sql | 43 ++++++++ web/src/api/types.ts | 7 ++ web/src/components/RealtimeEventsProvider.tsx | 7 ++ web/src/pages/Notifications.tsx | 11 ++ .../pages/settings/NotificationsSettings.tsx | 23 ++-- 26 files changed, 728 insertions(+), 54 deletions(-) create mode 100644 internal/notifications/operational_dispatch.go create mode 100644 internal/notifications/request_notifier.go create mode 100644 internal/requests/notify.go create mode 100644 internal/requests/notify_test.go create mode 100644 migrations/sql/20260611193802_request_fulfilled_notifications.sql diff --git a/cmd/silo/main.go b/cmd/silo/main.go index d09db96e..c0a3fbaf 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -1663,6 +1663,9 @@ func main() { ) requestReconcileSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(reconcileResolver)) } + if notificationSystem != nil { + requestReconcileSvc.SetFulfillmentNotifier(notifications.NewRequestFulfillmentNotifier(notificationSystem)) + } taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil { autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher) diff --git a/internal/api/handlers/notifications_webhooks.go b/internal/api/handlers/notifications_webhooks.go index 132049eb..c052d7a9 100644 --- a/internal/api/handlers/notifications_webhooks.go +++ b/internal/api/handlers/notifications_webhooks.go @@ -24,6 +24,7 @@ type webhookResponse struct { NotifyWatchlist bool `json:"notify_watchlist"` NotifyContinueWatching bool `json:"notify_continue_watching"` NotifyNextUp bool `json:"notify_next_up"` + NotifyRequests bool `json:"notify_requests"` ConsecutiveFailures int `json:"consecutive_failures"` DisabledReason *string `json:"disabled_reason"` LastSuccessAt *time.Time `json:"last_success_at"` @@ -45,6 +46,7 @@ func webhookToResponse(hook notifications.Webhook) webhookResponse { NotifyWatchlist: hook.NotifyWatchlist, NotifyContinueWatching: hook.NotifyContinueWatching, NotifyNextUp: hook.NotifyNextUp, + NotifyRequests: hook.NotifyRequests, ConsecutiveFailures: hook.ConsecutiveFailures, DisabledReason: hook.DisabledReason, LastSuccessAt: hook.LastSuccessAt, @@ -63,6 +65,7 @@ type webhookRequest struct { NotifyWatchlist *bool `json:"notify_watchlist"` NotifyContinueWatching *bool `json:"notify_continue_watching"` NotifyNextUp *bool `json:"notify_next_up"` + NotifyRequests *bool `json:"notify_requests"` } func (r webhookRequest) toInput() notifications.WebhookInput { @@ -75,6 +78,7 @@ func (r webhookRequest) toInput() notifications.WebhookInput { NotifyWatchlist: r.NotifyWatchlist, NotifyContinueWatching: r.NotifyContinueWatching, NotifyNextUp: r.NotifyNextUp, + NotifyRequests: r.NotifyRequests, } } diff --git a/internal/notifications/delivery_repo.go b/internal/notifications/delivery_repo.go index 2cd5fc57..e5aa58b7 100644 --- a/internal/notifications/delivery_repo.go +++ b/internal/notifications/delivery_repo.go @@ -228,28 +228,6 @@ func (r *DeliveryRepository) GetRowByID(ctx context.Context, id string) (*Delive return &out[0], nil } -// InsertOperational inserts a single operational delivery (e.g. -// webhook.auto_disabled) outside the fanout path. Returns nil when the row -// deduped away. -func (r *DeliveryRepository) InsertOperational(ctx context.Context, delivery Delivery) (*InsertedDelivery, error) { - tx, err := r.pool.Begin(ctx) - if err != nil { - return nil, fmt.Errorf("begin operational delivery tx: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() - inserted, err := r.BulkInsert(ctx, tx, []Delivery{delivery}) - if err != nil { - return nil, err - } - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit operational delivery: %w", err) - } - if len(inserted) == 0 { - return nil, nil - } - return &inserted[0], nil -} - // RecentUnread returns the newest unread rows for the websocket snapshot. func (r *DeliveryRepository) RecentUnread(ctx context.Context, profileID string, limit int) ([]DeliveryRow, error) { rows, err := r.pool.Query(ctx, diff --git a/internal/notifications/operational_dispatch.go b/internal/notifications/operational_dispatch.go new file mode 100644 index 00000000..4a5ab164 --- /dev/null +++ b/internal/notifications/operational_dispatch.go @@ -0,0 +1,99 @@ +package notifications + +import ( + "context" + "fmt" + + "github.com/oklog/ulid/v2" +) + +// OperationalDispatch describes how one operational delivery (a non-fanout +// notice such as webhook.auto_disabled or request.fulfilled) reaches the +// per-target channels. WebhookFilter selects which of the profile's enabled +// webhooks receive it; nil means the type must not reach webhooks at all +// (e.g. the auto-disable notice's loop guard). Web push has no per-type +// filter: profile-level gating happens before dispatch. +type OperationalDispatch struct { + WebhookFilter func(Webhook) bool +} + +// DispatchOperational durably creates one operational delivery. The inbox row +// and the per-target webhook / web push outbox rows commit in a single +// transaction — a crash afterwards delays channel sends instead of dropping +// them, because the retry workers recover pending outbox rows — then realtime +// and channel dispatch run post-commit. Returns nil when the delivery deduped +// away (the partial unique indexes make operational notices idempotent). +func (s *System) DispatchOperational(ctx context.Context, delivery Delivery, opts OperationalDispatch) (*InsertedDelivery, error) { + if s == nil { + return nil, nil + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin operational dispatch tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + inserted, err := s.Deliveries.BulkInsert(ctx, tx, []Delivery{delivery}) + if err != nil { + return nil, err + } + if len(inserted) == 0 { + return nil, nil + } + row := inserted[0] + + if opts.WebhookFilter != nil && s.webhookRepo != nil && s.Settings.WebhooksEnabled(ctx) { + hooksByProfile, err := s.webhookRepo.ListEnabledByProfiles(ctx, tx, []string{delivery.ProfileID}) + if err != nil { + return nil, err + } + attempts := make([]DeliveryAttempt, 0, 2) + for _, hook := range hooksByProfile[delivery.ProfileID] { + if !opts.WebhookFilter(hook) { + continue + } + attempts = append(attempts, DeliveryAttempt{ + ID: ulid.Make().String(), + NotificationDeliveryID: row.ID, + TargetID: hook.ID, + }) + } + if err := s.webhookRepo.EnqueueAttempts(ctx, tx, attempts); err != nil { + return nil, err + } + } + if s.webPushRepo != nil && s.Settings.WebPushEnabled(ctx) { + subsByProfile, err := s.webPushRepo.ListEnabledByProfiles(ctx, tx, []string{delivery.ProfileID}) + if err != nil { + return nil, err + } + attempts := make([]DeliveryAttempt, 0, 2) + for _, sub := range subsByProfile[delivery.ProfileID] { + attempts = append(attempts, DeliveryAttempt{ + ID: ulid.Make().String(), + NotificationDeliveryID: row.ID, + TargetID: sub.ID, + }) + } + if err := s.webPushRepo.EnqueueAttempts(ctx, tx, attempts); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit operational dispatch: %w", err) + } + + // Post-commit dispatch is best-effort: the durable inbox row covers + // websocket reconnect, and the retry workers recover the outbox rows. + full, err := s.Deliveries.GetRowByID(ctx, row.ID) + if err != nil || full == nil { + s.logger.Warn("operational delivery reload failed", + "delivery_id", row.ID, "error", err) + return &row, nil + } + if err := s.dispatcher.Dispatch(ctx, *full); err != nil { + s.logger.Warn("operational delivery dispatch failed", + "delivery_id", row.ID, "error", err) + } + return &row, nil +} diff --git a/internal/notifications/request_notifier.go b/internal/notifications/request_notifier.go new file mode 100644 index 00000000..97225c1a --- /dev/null +++ b/internal/notifications/request_notifier.go @@ -0,0 +1,90 @@ +package notifications + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Silo-Server/silo-server/internal/requests" + "github.com/oklog/ulid/v2" +) + +// DeliveryTypeRequestFulfilled is the operational notice posted to the +// requesting profile once its media request is present in the catalog +// (docs/superpowers/plans/notifications/06, item 2). Its reason_flags carry +// {"request_id","tmdb_id","media_type"} instead of reason booleans; a partial +// unique index on (profile_id, request_id) makes the insert idempotent, and +// the per-webhook notify_requests flag gates the webhook channel. +const DeliveryTypeRequestFulfilled = "request.fulfilled" + +// RequestFulfilledFlags is the decoded reason_flags shape for +// request.fulfilled deliveries. +type RequestFulfilledFlags struct { + RequestID string `json:"request_id"` + TMDBID int `json:"tmdb_id"` + MediaType string `json:"media_type"` +} + +// parseRequestFulfilledFlags decodes a request.fulfilled delivery's +// reason_flags; other types decode to the zero value. +func parseRequestFulfilledFlags(raw []byte) RequestFulfilledFlags { + var flags RequestFulfilledFlags + if len(raw) > 0 { + _ = json.Unmarshal(raw, &flags) + } + return flags +} + +// RequestFulfillmentNotifier adapts the notification system to +// requests.FulfillmentNotifier: it gates on the profile's master toggle and +// dispatches one durable request.fulfilled delivery across all channels. +type RequestFulfillmentNotifier struct { + system *System +} + +// NewRequestFulfillmentNotifier creates the adapter. +func NewRequestFulfillmentNotifier(system *System) *RequestFulfillmentNotifier { + return &RequestFulfillmentNotifier{system: system} +} + +// NotifyFulfilled implements requests.FulfillmentNotifier. contentID is the +// matched catalog item: deliveryRowSelect joins media_items on series_id, so +// that one field renders the title, poster, and deep link for movies and +// series alike. Returning nil without dispatching (master toggle off, missing +// attribution) still counts as handled — the caller stamps the request either +// way. +func (n *RequestFulfillmentNotifier) NotifyFulfilled(ctx context.Context, req requests.Request, contentID string) error { + if n == nil || n.system == nil { + return nil + } + if req.RequestedByProfileID == "" || req.RequestedByUserID <= 0 { + return nil // legacy rows without attribution have no recipient + } + prefs, err := n.system.Preferences.Get(ctx, req.RequestedByProfileID) + if err != nil { + return err + } + if !prefs.Enabled { + return nil + } + flags, err := json.Marshal(RequestFulfilledFlags{ + RequestID: req.ID, + TMDBID: req.TMDBID, + MediaType: string(req.MediaType), + }) + if err != nil { + return fmt.Errorf("marshal request fulfilled flags: %w", err) + } + delivery := Delivery{ + ID: ulid.Make().String(), + UserID: req.RequestedByUserID, + ProfileID: req.RequestedByProfileID, + SeriesID: &contentID, + Type: DeliveryTypeRequestFulfilled, + ReasonFlags: flags, + } + _, err = n.system.DispatchOperational(ctx, delivery, OperationalDispatch{ + WebhookFilter: func(hook Webhook) bool { return hook.NotifyRequests }, + }) + return err +} diff --git a/internal/notifications/system.go b/internal/notifications/system.go index ec69e2c2..dfe77961 100644 --- a/internal/notifications/system.go +++ b/internal/notifications/system.go @@ -55,6 +55,10 @@ type System struct { webhookRetry *WebhookRetryWorker webPushRepo *WebPushRepository webPushDispatcher *WebPushDispatcher + // dispatcher is the same MultiDispatcher the fanout worker uses; the + // operational dispatch path shares it so every delivery reaches every + // configured channel the same way. + dispatcher Dispatcher pool *pgxpool.Pool stores userstore.UserStoreProvider @@ -94,7 +98,7 @@ func NewSystem( var sender *webhookSender if cipher != nil { webhookRepo = NewWebhookRepository(pool) - sender = newWebhookSender(webhookRepo, deliveries, cipher, settings, hub) + sender = newWebhookSender(webhookRepo, deliveries, cipher, settings) webhookService = newWebhookService(webhookRepo, cipher, settings, sender) webhookDispatcher = newWebhookDispatcher(sender) webhookRetry = newWebhookRetryWorker(sender) @@ -116,7 +120,8 @@ func NewSystem( dispatchers = append(dispatchers, webPushDispatcher) } - fanout := NewFanoutWorker(pool, releases, interests, deliveries, preferences, settings, NewMultiDispatcher(dispatchers...)) + multiDispatcher := NewMultiDispatcher(dispatchers...) + fanout := NewFanoutWorker(pool, releases, interests, deliveries, preferences, settings, multiDispatcher) if webhookRepo != nil { fanout.SetWebhookOutbox(webhookRepo, newProfileRateLimiter()) } @@ -144,6 +149,7 @@ func NewSystem( webhookRetry: webhookRetry, webPushRepo: webPushRepo, webPushDispatcher: webPushDispatcher, + dispatcher: multiDispatcher, pool: pool, stores: stores, users: users, @@ -151,7 +157,7 @@ func NewSystem( } wsDispatcher.payload = system.PayloadForRow if sender != nil { - sender.payload = system.PayloadForRow + sender.operational = system.DispatchOperational } if webPushSenderInst != nil { webPushSenderInst.payload = system.PayloadForRow diff --git a/internal/notifications/webhook_logic_test.go b/internal/notifications/webhook_logic_test.go index 91315aa5..74c5aa64 100644 --- a/internal/notifications/webhook_logic_test.go +++ b/internal/notifications/webhook_logic_test.go @@ -163,6 +163,85 @@ func TestBuildDiscordWebhookPayload(t *testing.T) { } } +func requestFulfilledTestRow() DeliveryRow { + contentID := "movie-123" + return DeliveryRow{ + Delivery: Delivery{ + ID: "01REQUEST", + ProfileID: "profile-1", + SeriesID: &contentID, + Type: DeliveryTypeRequestFulfilled, + ReasonFlags: []byte(`{"request_id":"01REQ","tmdb_id":438631,"media_type":"movie"}`), + CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), + }, + SeriesTitle: "Dune", + } +} + +func TestBuildDiscordWebhookPayloadRequestFulfilled(t *testing.T) { + payload, err := BuildDiscordWebhookPayload(requestFulfilledTestRow(), false) + if err != nil { + t.Fatalf("build failed: %v", err) + } + var body struct { + Embeds []struct { + Title string `json:"title"` + Description string `json:"description"` + Fields []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"fields"` + } `json:"embeds"` + } + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if len(body.Embeds) != 1 { + t.Fatalf("unexpected body shape: %+v", body) + } + embed := body.Embeds[0] + if embed.Title != "Dune" { + t.Fatalf("unexpected title %q", embed.Title) + } + if embed.Description != "Your media request is now available on Silo" { + t.Fatalf("unexpected description %q", embed.Description) + } + if len(embed.Fields) != 1 || embed.Fields[0].Name != "Type" || embed.Fields[0].Value != "Movie" { + t.Fatalf("expected a single Type=Movie field, got %+v", embed.Fields) + } +} + +func TestGenericWebhookPayloadRequestFulfilled(t *testing.T) { + payload, err := BuildGenericWebhookPayload(requestFulfilledTestRow(), "hook-1", false) + if err != nil { + t.Fatalf("build failed: %v", err) + } + var body struct { + Type string `json:"type"` + Series *struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"series"` + Request *struct { + ID string `json:"id"` + TMDBID int `json:"tmdb_id"` + MediaType string `json:"media_type"` + } `json:"request"` + } + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if body.Type != DeliveryTypeRequestFulfilled { + t.Fatalf("unexpected type %q", body.Type) + } + if body.Request == nil || body.Request.ID != "01REQ" || body.Request.TMDBID != 438631 || body.Request.MediaType != "movie" { + t.Fatalf("unexpected request block: %+v", body.Request) + } + if body.Series == nil || body.Series.ID != "movie-123" || body.Series.Title != "Dune" { + t.Fatalf("unexpected series block: %+v", body.Series) + } +} + func TestBuildDiscordWebhookPayloadTestMarker(t *testing.T) { payload, err := BuildDiscordWebhookPayload(webhookTestRow(), true) if err != nil { diff --git a/internal/notifications/webhook_payload_discord.go b/internal/notifications/webhook_payload_discord.go index 09fde7fb..d1845df5 100644 --- a/internal/notifications/webhook_payload_discord.go +++ b/internal/notifications/webhook_payload_discord.go @@ -66,6 +66,9 @@ func BuildDiscordWebhookPayload(row DeliveryRow, test bool) ([]byte, error) { title = truncateWithEllipsis(title, discordTitleLimit) description := "New episode available on Silo" + if row.Type == DeliveryTypeRequestFulfilled { + description = "Your media request is now available on Silo" + } footerText := "Silo" if row.SeriesTitle != "" { footerText = "Silo • " + truncateWithEllipsis(row.SeriesTitle, discordFooterLimit-16) @@ -82,6 +85,11 @@ func BuildDiscordWebhookPayload(row DeliveryRow, test bool) ([]byte, error) { Inline: true, }) } + if row.Type == DeliveryTypeRequestFulfilled { + if mediaType := requestMediaTypeLabel(row.ReasonFlags); mediaType != "" { + fields = append(fields, discordEmbedField{Name: "Type", Value: mediaType, Inline: true}) + } + } if row.SeasonNumber != nil { fields = append(fields, discordEmbedField{ Name: "Season", @@ -116,7 +124,15 @@ func BuildDiscordWebhookPayload(row DeliveryRow, test bool) ([]byte, error) { } func discordEmbedTitle(row DeliveryRow) string { - if row.Type != DeliveryTypeEpisodeAvailable { + switch row.Type { + case DeliveryTypeRequestFulfilled: + if row.SeriesTitle != "" { + return row.SeriesTitle + } + return "Request fulfilled" + case DeliveryTypeEpisodeAvailable: + // Falls out of the switch into the episode title assembly below. + default: return genericNotificationTitle } series := row.SeriesTitle @@ -137,6 +153,19 @@ func discordEmbedTitle(row DeliveryRow) string { } } +// requestMediaTypeLabel renders a request.fulfilled delivery's media type as +// a display label; unknown values render nothing. +func requestMediaTypeLabel(reasonFlags []byte) string { + switch parseRequestFulfilledFlags(reasonFlags).MediaType { + case "movie": + return "Movie" + case "series": + return "Series" + default: + return "" + } +} + func discordEmbedColor(flags ReasonFlags) int { switch { case flags.Favorite: diff --git a/internal/notifications/webhook_payload_generic.go b/internal/notifications/webhook_payload_generic.go index f3e08710..6609191e 100644 --- a/internal/notifications/webhook_payload_generic.go +++ b/internal/notifications/webhook_payload_generic.go @@ -28,6 +28,9 @@ type genericWebhookBody struct { Reasons ReasonFlags `json:"reason_flags"` Series *genericWebhookSeries `json:"series,omitempty"` Episode *genericWebhookEpisode `json:"episode,omitempty"` + // Request is present for request.fulfilled deliveries; the catalog item is + // in Series (movies included — the field carries the matched item). + Request *genericWebhookRequest `json:"request,omitempty"` } type genericWebhookSeries struct { @@ -42,6 +45,12 @@ type genericWebhookEpisode struct { EpisodeNumber *int `json:"episode_number,omitempty"` } +type genericWebhookRequest struct { + ID string `json:"id"` + TMDBID int `json:"tmdb_id,omitempty"` + MediaType string `json:"media_type,omitempty"` +} + // BuildGenericWebhookPayload renders a delivery as canonical Silo JSON. Pure // function. func BuildGenericWebhookPayload(row DeliveryRow, webhookID string, test bool) ([]byte, error) { @@ -72,6 +81,14 @@ func BuildGenericWebhookPayload(row DeliveryRow, webhookID string, test bool) ([ EpisodeNumber: row.EpisodeNumber, } } + if row.Type == DeliveryTypeRequestFulfilled { + flags := parseRequestFulfilledFlags(row.ReasonFlags) + body.Request = &genericWebhookRequest{ + ID: flags.RequestID, + TMDBID: flags.TMDBID, + MediaType: flags.MediaType, + } + } return json.Marshal(body) } diff --git a/internal/notifications/webhook_repo.go b/internal/notifications/webhook_repo.go index 7374f008..ef988489 100644 --- a/internal/notifications/webhook_repo.go +++ b/internal/notifications/webhook_repo.go @@ -25,6 +25,7 @@ const webhookColumns = ` id, user_id, profile_id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled, notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up, + notify_requests, consecutive_failures, disabled_reason, last_success_at, last_failure_at, last_failure_status, last_failure_message, created_at, updated_at` @@ -35,6 +36,7 @@ func scanWebhook(row pgx.Row) (*Webhook, error) { &hook.ID, &hook.UserID, &hook.ProfileID, &hook.Name, &hook.Type, &hook.URLCiphertext, &hook.URLHost, &hook.SigningSecretCiphertext, &hook.Enabled, &hook.NotifyFavorites, &hook.NotifyWatchlist, &hook.NotifyContinueWatching, &hook.NotifyNextUp, + &hook.NotifyRequests, &hook.ConsecutiveFailures, &hook.DisabledReason, &hook.LastSuccessAt, &hook.LastFailureAt, &hook.LastFailureStatus, &hook.LastFailureMessage, &hook.CreatedAt, &hook.UpdatedAt, @@ -138,11 +140,13 @@ func (r *WebhookRepository) Insert(ctx context.Context, hook Webhook) error { INSERT INTO notification_webhooks (id, user_id, profile_id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled, - notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up, + notify_requests) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, hook.ID, hook.UserID, hook.ProfileID, hook.Name, hook.Type, hook.URLCiphertext, hook.URLHost, hook.SigningSecretCiphertext, hook.Enabled, - hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp) + hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp, + hook.NotifyRequests) if err != nil { if strings.Contains(err.Error(), "notification_webhooks_profile_name_key") { return ErrWebhookNameTaken @@ -161,12 +165,14 @@ func (r *WebhookRepository) Update(ctx context.Context, hook Webhook) error { signing_secret_ciphertext = $6, enabled = $7, notify_favorites = $8, notify_watchlist = $9, notify_continue_watching = $10, notify_next_up = $11, - consecutive_failures = $12, disabled_reason = $13, + notify_requests = $12, + consecutive_failures = $13, disabled_reason = $14, updated_at = now() WHERE id = $1`, hook.ID, hook.Name, hook.Type, hook.URLCiphertext, hook.URLHost, hook.SigningSecretCiphertext, hook.Enabled, hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp, + hook.NotifyRequests, hook.ConsecutiveFailures, hook.DisabledReason) if err != nil { if strings.Contains(err.Error(), "notification_webhooks_profile_name_key") { diff --git a/internal/notifications/webhook_sender.go b/internal/notifications/webhook_sender.go index 2bb1406b..8a50288b 100644 --- a/internal/notifications/webhook_sender.go +++ b/internal/notifications/webhook_sender.go @@ -9,7 +9,6 @@ import ( "sync" "time" - evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/secret" "github.com/oklog/ulid/v2" ) @@ -69,9 +68,10 @@ type webhookSender struct { cipher *secret.Cipher settings *Settings client *http.Client - hub *evt.Hub - payload func(ctx context.Context, row DeliveryRow) DeliveryRowPayload - logger *slog.Logger + // operational posts the auto-disable notice through the system's shared + // durable dispatch path. Wired by NewSystem after construction. + operational func(ctx context.Context, delivery Delivery, opts OperationalDispatch) (*InsertedDelivery, error) + logger *slog.Logger } func newWebhookSender( @@ -79,14 +79,12 @@ func newWebhookSender( deliveries *DeliveryRepository, cipher *secret.Cipher, settings *Settings, - hub *evt.Hub, ) *webhookSender { sender := &webhookSender{ webhooks: webhooks, deliveries: deliveries, cipher: cipher, settings: settings, - hub: hub, logger: slog.Default().With("component", "notifications.webhooks"), } sender.client = newWebhookHTTPClient(func() bool { @@ -267,20 +265,13 @@ func (s *webhookSender) disableWebhook(ctx context.Context, hook *Webhook, resul Type: DeliveryTypeWebhookAutoDisabled, ReasonFlags: noticeFlags, } - inserted, err := s.deliveries.InsertOperational(ctx, notice) - if err != nil || inserted == nil { - s.logger.Warn("webhook auto-disable notice insert failed", "webhook_id", hook.ID, "error", err) + if s.operational == nil { return } - notice.ID = inserted.ID - notice.CreatedAt = inserted.CreatedAt - if s.hub != nil { - payload := PayloadForRow(DeliveryRow{Delivery: notice}) - if s.payload != nil { - payload = s.payload(ctx, DeliveryRow{Delivery: notice}) - } - _ = s.hub.PublishJSON(ctx, evt.ChannelNotifications, EventNotificationCreated, - payload, evt.PublishOptions{UserID: hook.UserID, ProfileID: hook.ProfileID}) + // Nil WebhookFilter: the notice must never re-dispatch as a webhook, or a + // broken webhook would loop forever. + if _, err := s.operational(ctx, notice, OperationalDispatch{}); err != nil { + s.logger.Warn("webhook auto-disable notice dispatch failed", "webhook_id", hook.ID, "error", err) } } diff --git a/internal/notifications/webhook_service.go b/internal/notifications/webhook_service.go index d1cd932d..dca26874 100644 --- a/internal/notifications/webhook_service.go +++ b/internal/notifications/webhook_service.go @@ -46,6 +46,7 @@ type WebhookInput struct { NotifyWatchlist *bool NotifyContinueWatching *bool NotifyNextUp *bool + NotifyRequests *bool } func validateWebhookName(name string) (string, error) { @@ -130,6 +131,7 @@ func (s *WebhookService) Create(ctx context.Context, userID int, profileID strin NotifyWatchlist: boolOrDefault(input.NotifyWatchlist, true), NotifyContinueWatching: boolOrDefault(input.NotifyContinueWatching, true), NotifyNextUp: boolOrDefault(input.NotifyNextUp, true), + NotifyRequests: boolOrDefault(input.NotifyRequests, true), } hook.URLCiphertext, err = s.cipher.Encrypt(rawURL, webhookURLAAD(hook.ID)) if err != nil { @@ -215,6 +217,9 @@ func (s *WebhookService) Update(ctx context.Context, profileID, id string, input if input.NotifyNextUp != nil { hook.NotifyNextUp = *input.NotifyNextUp } + if input.NotifyRequests != nil { + hook.NotifyRequests = *input.NotifyRequests + } if err := s.repo.Update(ctx, *hook); err != nil { if errors.Is(err, ErrWebhookNameTaken) { diff --git a/internal/notifications/webhook_types.go b/internal/notifications/webhook_types.go index b94517bb..9ab71d13 100644 --- a/internal/notifications/webhook_types.go +++ b/internal/notifications/webhook_types.go @@ -46,6 +46,7 @@ type Webhook struct { NotifyWatchlist bool NotifyContinueWatching bool NotifyNextUp bool + NotifyRequests bool ConsecutiveFailures int DisabledReason *string LastSuccessAt *time.Time diff --git a/internal/notifications/webpush_logic_test.go b/internal/notifications/webpush_logic_test.go index 3d821ef9..ffb078c8 100644 --- a/internal/notifications/webpush_logic_test.go +++ b/internal/notifications/webpush_logic_test.go @@ -32,6 +32,26 @@ func TestBuildWebPushPayload(t *testing.T) { } }) + t.Run("request fulfilled deep-links to the matched item", func(t *testing.T) { + raw, err := buildWebPushPayload(requestFulfilledTestRow(), "https://cdn.example.com/poster.jpg") + if err != nil { + t.Fatal(err) + } + var payload webPushPayload + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + if payload.Title != "Dune is now available" { + t.Fatalf("unexpected title %q", payload.Title) + } + if payload.URL != "/item/movie-123" { + t.Fatalf("unexpected url %q", payload.URL) + } + if payload.Icon != "https://cdn.example.com/poster.jpg" { + t.Fatalf("unexpected icon %q", payload.Icon) + } + }) + t.Run("webhook auto-disable routes to settings", func(t *testing.T) { row := DeliveryRow{Delivery: Delivery{ID: "01X", Type: DeliveryTypeWebhookAutoDisabled}} raw, err := buildWebPushPayload(row, "") diff --git a/internal/notifications/webpush_sender.go b/internal/notifications/webpush_sender.go index 7c0fdb5c..118bf4df 100644 --- a/internal/notifications/webpush_sender.go +++ b/internal/notifications/webpush_sender.go @@ -81,6 +81,17 @@ func buildWebPushPayload(row DeliveryRow, posterURL string) ([]byte, error) { payload.URL = "/item/" + *row.EpisodeID } payload.Icon = posterURL + case DeliveryTypeRequestFulfilled: + if row.SeriesTitle != "" { + payload.Title = row.SeriesTitle + " is now available" + } else { + payload.Title = "Your request is now available" + } + payload.Body = "Your media request has arrived in the library." + if row.SeriesID != nil { + payload.URL = "/item/" + *row.SeriesID + } + payload.Icon = posterURL case DeliveryTypeWebhookAutoDisabled: payload.Title = "A webhook stopped working" payload.Body = "Open notification settings to fix it." diff --git a/internal/requests/notify.go b/internal/requests/notify.go new file mode 100644 index 00000000..a5b983ff --- /dev/null +++ b/internal/requests/notify.go @@ -0,0 +1,66 @@ +package requests + +import ( + "context" + "log/slog" +) + +// FulfillmentNotifier delivers the request.fulfilled notification once a +// completed request's media is confirmed present in the catalog. contentID is +// the matched catalog item id. Implementations must tolerate repeat calls for +// the same request (delivery creation is idempotent); returning nil means the +// request counts as handled and will not be retried. +type FulfillmentNotifier interface { + NotifyFulfilled(ctx context.Context, req Request, contentID string) error +} + +// SetFulfillmentNotifier wires the notification system into the reconcile +// service. Optional; without it completed requests are never notified. +func (s *Service) SetFulfillmentNotifier(n FulfillmentNotifier) { s.notifier = n } + +// notifyFulfilledLimit bounds one notification pass; the remainder lands on +// the next reconcile run. +const notifyFulfilledLimit = 100 + +// notifyFulfilledPending notifies completed requests whose media has arrived +// in the catalog. Requests completed by an integration before the library +// scan imports the files stay pending (fulfilled_notified_at IS NULL) and are +// re-checked every run until presence confirms — the notification means +// "watchable in Silo", not "download finished". The delivery insert is +// idempotent (partial unique index per request), so the notify-then-stamp +// ordering can never double-send: a crash between the two retries into a +// dedupe no-op. +func (s *Service) notifyFulfilledPending(ctx context.Context) { + if s.notifier == nil { + return + } + candidates, err := s.store.ListFulfilledUnnotified(ctx, notifyFulfilledLimit) + if err != nil { + slog.WarnContext(ctx, "request fulfill-notify: list candidates failed", "err", err) + return + } + for _, req := range candidates { + if ctx.Err() != nil { + return + } + matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(*req)}) + if err != nil { + slog.WarnContext(ctx, "request fulfill-notify: presence lookup failed", + "request_id", req.ID, "tmdb_id", req.TMDBID, "err", err) + continue + } + match := matches[req.TMDBID] + if !match.Available { + continue // not in the catalog yet; retry next run + } + if err := s.notifier.NotifyFulfilled(ctx, *req, match.ContentID); err != nil { + slog.WarnContext(ctx, "request fulfill-notify: dispatch failed", + "request_id", req.ID, "err", err) + continue + } + if err := s.store.MarkFulfilledNotified(ctx, req.ID); err != nil { + slog.WarnContext(ctx, "request fulfill-notify: mark failed", + "request_id", req.ID, "err", err) + } + } +} diff --git a/internal/requests/notify_test.go b/internal/requests/notify_test.go new file mode 100644 index 00000000..cbd2cd08 --- /dev/null +++ b/internal/requests/notify_test.go @@ -0,0 +1,101 @@ +package requests + +import ( + "context" + "errors" + "testing" +) + +type fakeNotifier struct { + requestIDs []string + contentIDs []string + err error +} + +func (f *fakeNotifier) NotifyFulfilled(_ context.Context, req Request, contentID string) error { + if f.err != nil { + return f.err + } + f.requestIDs = append(f.requestIDs, req.ID) + f.contentIDs = append(f.contentIDs, contentID) + return nil +} + +func completedRequestFixture(id string, tmdbID int) *Request { + return &Request{ + ID: id, + MediaType: MediaTypeMovie, + TMDBID: tmdbID, + Title: "Fixture Movie", + Status: StatusCompleted, + Outcome: OutcomeActive, + RequestedByUserID: 7, + RequestedByProfileID: "profile-1", + } +} + +func TestNotifyFulfilledPendingNotifiesAndMarks(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + presence := &fakePresence{available: map[MediaType]map[int]bool{ + MediaTypeMovie: {42: true}, + }} + notifier := &fakeNotifier{} + service := NewService(store, &fakeTMDBClient{}, presence) + service.SetFulfillmentNotifier(notifier) + + service.notifyFulfilledPending(context.Background()) + + if len(notifier.requestIDs) != 1 || notifier.requestIDs[0] != "req1" { + t.Fatalf("expected one notification for req1, got %v", notifier.requestIDs) + } + if want := fakePresenceContentID(MediaTypeMovie, 42); notifier.contentIDs[0] != want { + t.Fatalf("expected content id %q, got %q", want, notifier.contentIDs[0]) + } + if len(store.notified) != 1 || store.notified[0] != "req1" { + t.Fatalf("expected req1 marked notified, got %v", store.notified) + } +} + +func TestNotifyFulfilledPendingWaitsForCatalogMatch(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + notifier := &fakeNotifier{} + service := NewService(store, &fakeTMDBClient{}, &fakePresence{}) + service.SetFulfillmentNotifier(notifier) + + service.notifyFulfilledPending(context.Background()) + + if len(notifier.requestIDs) != 0 { + t.Fatalf("expected no notification before catalog match, got %v", notifier.requestIDs) + } + if len(store.notified) != 0 { + t.Fatalf("expected request to stay pending, got marked %v", store.notified) + } + if len(store.unnotified) != 1 { + t.Fatalf("expected request to remain in the pending set") + } +} + +func TestNotifyFulfilledPendingRetriesAfterNotifierError(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + presence := &fakePresence{available: map[MediaType]map[int]bool{ + MediaTypeMovie: {42: true}, + }} + notifier := &fakeNotifier{err: errors.New("dispatch failed")} + service := NewService(store, &fakeTMDBClient{}, presence) + service.SetFulfillmentNotifier(notifier) + + service.notifyFulfilledPending(context.Background()) + + if len(store.notified) != 0 { + t.Fatalf("a failed dispatch must not mark the request notified, got %v", store.notified) + } + if len(store.unnotified) != 1 { + t.Fatalf("expected request to remain pending for the next run") + } +} diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 89ceae42..71a08462 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -374,6 +374,53 @@ func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int return out, nil } +// ListFulfilledUnnotified returns completed requests whose fulfillment +// notification has not fired, oldest first. The horizon bounds how long a +// completed request keeps being presence-polled when its media never appears +// in the catalog (requests completed before the feature shipped are stamped +// by the migration backfill, so the horizon is defense in depth). +func (r *Repository) ListFulfilledUnnotified(ctx context.Context, limit int) ([]*Request, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := r.pool.Query(ctx, requestSelectSQL()+` + WHERE outcome = 'active' + AND status = 'completed' + AND fulfilled_notified_at IS NULL + AND completed_at > now() - interval '30 days' + ORDER BY completed_at ASC + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list fulfilled unnotified requests: %w", err) + } + defer rows.Close() + + var out []*Request + for rows.Next() { + req, err := scanRequest(rows) + if err != nil { + return nil, err + } + out = append(out, req) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate fulfilled unnotified requests: %w", err) + } + return out, nil +} + +// MarkFulfilledNotified stamps the fulfillment-notification marker. Idempotent. +func (r *Repository) MarkFulfilledNotified(ctx context.Context, id string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE media_requests SET fulfilled_notified_at = now() + WHERE id = $1 AND fulfilled_notified_at IS NULL`, id) + if err != nil { + return fmt.Errorf("mark request fulfill-notified: %w", err) + } + return nil +} + func (r *Repository) ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) { sqlText, args := buildRequestListSQL("requested_by_user_id = $1", []any{userID}, filter) return r.listRequests(ctx, sqlText, args) diff --git a/internal/requests/service.go b/internal/requests/service.go index 9bd57125..420e98fe 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -47,6 +47,7 @@ type Service struct { router RequestRouterProvider entitlements EntitlementResolver requesterIdentity RequesterIdentityResolver + notifier FulfillmentNotifier Now func() time.Time } @@ -719,6 +720,12 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe result.Skipped++ } } + // Presence-gated fulfillment notifications: completion above (and via the + // per-target aggregate path) only marks status; the notification fires + // once the media is confirmed present in the catalog. + if s.notifier != nil { + s.notifyFulfilledPending(ctx) + } return result, nil } diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index b7550a83..fbdbabd4 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -1308,6 +1308,8 @@ type fakeStore struct { requests map[string]*Request targets map[string][]Target targetSeq int64 + unnotified []string + notified []string listIntegrationsCalls int getSettingsCalls int @@ -1453,6 +1455,33 @@ func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Reque return f.candidates, nil } +func (f *fakeStore) ListFulfilledUnnotified(context.Context, int) ([]*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*Request, 0, len(f.unnotified)) + for _, id := range f.unnotified { + if req := f.requests[id]; req != nil { + copy := *req + out = append(out, ©) + } + } + return out, nil +} + +func (f *fakeStore) MarkFulfilledNotified(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + kept := f.unnotified[:0] + for _, pending := range f.unnotified { + if pending != id { + kept = append(kept, pending) + } + } + f.unnotified = kept + f.notified = append(f.notified, id) + return nil +} + func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/requests/store.go b/internal/requests/store.go index c70a8bae..2b0d8ebf 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -18,6 +18,12 @@ type Store interface { CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) GetRequest(ctx context.Context, id string) (*Request, error) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) + // ListFulfilledUnnotified returns completed requests whose fulfillment + // notification has not fired yet (presence-gated notify pass). + ListFulfilledUnnotified(ctx context.Context, limit int) ([]*Request, error) + // MarkFulfilledNotified stamps a request's fulfillment-notification + // marker so the notify pass stops considering it. Idempotent. + MarkFulfilledNotified(ctx context.Context, id string) error ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) diff --git a/migrations/sql/20260611193802_request_fulfilled_notifications.sql b/migrations/sql/20260611193802_request_fulfilled_notifications.sql new file mode 100644 index 00000000..c1aedaf3 --- /dev/null +++ b/migrations/sql/20260611193802_request_fulfilled_notifications.sql @@ -0,0 +1,43 @@ +-- +goose Up +-- +goose StatementBegin +-- request.fulfilled notifications (docs/superpowers/plans/notifications/06, +-- item 2): notify the requesting profile once its media request is actually +-- present in the catalog. + +-- Notify marker. NULL means "completed but the fulfillment notification has +-- not fired yet"; the reconcile task keeps presence-checking such requests and +-- stamps this after the delivery is created (or suppressed by preferences). +ALTER TABLE public.media_requests + ADD COLUMN fulfilled_notified_at timestamptz; + +-- Flood safety: requests completed before this feature shipped must never +-- notify. Stamp them as already handled so only future completions fire. +UPDATE public.media_requests +SET fulfilled_notified_at = COALESCE(completed_at, now()) +WHERE status = 'completed'; + +-- The reconcile task scans for pending notifications on every run. +CREATE INDEX media_requests_fulfill_notify_idx + ON public.media_requests (completed_at) + WHERE status = 'completed' AND fulfilled_notified_at IS NULL; + +-- Per-webhook opt-out for request notifications. Defaults on: every channel +-- the requester configured should tell them (06, item 2). +ALTER TABLE public.notification_webhooks + ADD COLUMN notify_requests boolean NOT NULL DEFAULT true; + +-- At-most-once per (profile, request): the operational insert path uses +-- ON CONFLICT DO NOTHING, so a reconcile crash-retry or multi-node race +-- dedupes here instead of double-notifying. +CREATE UNIQUE INDEX notification_deliveries_profile_request_key + ON public.notification_deliveries (profile_id, (reason_flags->>'request_id')) + WHERE type = 'request.fulfilled'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS public.notification_deliveries_profile_request_key; +ALTER TABLE public.notification_webhooks DROP COLUMN IF EXISTS notify_requests; +DROP INDEX IF EXISTS public.media_requests_fulfill_notify_idx; +ALTER TABLE public.media_requests DROP COLUMN IF EXISTS fulfilled_notified_at; +-- +goose StatementEnd diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 55e00409..7ebe48b0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -2225,10 +2225,15 @@ export type EventChannel = | "notifications"; export interface NotificationReasonFlags { + // episode.available reasons favorite?: boolean; watchlist?: boolean; continue_watching?: boolean; next_up?: boolean; + // request.fulfilled operational payload + request_id?: string; + tmdb_id?: number; + media_type?: string; } export interface AppNotification { @@ -2292,6 +2297,7 @@ export interface NotificationWebhook { notify_watchlist: boolean; notify_continue_watching: boolean; notify_next_up: boolean; + notify_requests: boolean; consecutive_failures: number; disabled_reason: string | null; last_success_at: string | null; @@ -2311,6 +2317,7 @@ export interface NotificationWebhookInput { notify_watchlist?: boolean; notify_continue_watching?: boolean; notify_next_up?: boolean; + notify_requests?: boolean; } export interface NotificationWebhookTestResult { diff --git a/web/src/components/RealtimeEventsProvider.tsx b/web/src/components/RealtimeEventsProvider.tsx index 3e6a071b..1f11b95b 100644 --- a/web/src/components/RealtimeEventsProvider.tsx +++ b/web/src/components/RealtimeEventsProvider.tsx @@ -529,6 +529,13 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { toast(`New episode of ${notification.series_title}`, { description: [episodeCode, notification.episode_title].filter(Boolean).join(" — "), }); + } else if (notification.type === "request.fulfilled") { + toast( + notification.series_title + ? `${notification.series_title} is now available` + : "Your request is now available", + { description: "Your media request has arrived in the library." }, + ); } return; } diff --git a/web/src/pages/Notifications.tsx b/web/src/pages/Notifications.tsx index 4ccee7c5..84971e88 100644 --- a/web/src/pages/Notifications.tsx +++ b/web/src/pages/Notifications.tsx @@ -46,6 +46,9 @@ function notificationTitle(notification: AppNotification): string { if (notification.type === "episode.available") { return notification.series_title || "New episode available"; } + if (notification.type === "request.fulfilled") { + return notification.series_title || "Request available"; + } // Unknown types render with a generic fallback by design — the type // registry is extensible. return "Notification"; @@ -58,6 +61,14 @@ function notificationDescription(notification: AppNotification): string { [code, notification.episode_title].filter(Boolean).join(" — ") || "New episode available" ); } + if (notification.type === "request.fulfilled") { + const mediaType = notification.reason_flags?.media_type; + return mediaType === "movie" + ? "Your requested movie is now available" + : mediaType === "series" + ? "Your requested series is now available" + : "Your request is now available"; + } return notification.type; } diff --git a/web/src/pages/settings/NotificationsSettings.tsx b/web/src/pages/settings/NotificationsSettings.tsx index 9596dfd7..15a6a651 100644 --- a/web/src/pages/settings/NotificationsSettings.tsx +++ b/web/src/pages/settings/NotificationsSettings.tsx @@ -68,7 +68,14 @@ const REASON_FIELDS = [ { key: "notify_next_up", label: "Next Up" }, ] as const; -type ReasonKey = (typeof REASON_FIELDS)[number]["key"]; +// Webhooks additionally carry the request.fulfilled toggle; it is not an +// episode reason, so the profile preferences section keeps REASON_FIELDS. +const WEBHOOK_NOTIFY_FIELDS = [ + ...REASON_FIELDS, + { key: "notify_requests", label: "Requests" }, +] as const; + +type WebhookNotifyKey = (typeof WEBHOOK_NOTIFY_FIELDS)[number]["key"]; function formatRelativeTime(value: string | null): string | null { if (!value) { @@ -307,11 +314,12 @@ function WebhookFormDialog({ const update = useUpdateNotificationWebhook(); const [name, setName] = useState(webhook?.name ?? ""); const [url, setUrl] = useState(""); - const [reasons, setReasons] = useState>({ + const [reasons, setReasons] = useState>({ notify_favorites: webhook?.notify_favorites ?? true, notify_watchlist: webhook?.notify_watchlist ?? true, notify_continue_watching: webhook?.notify_continue_watching ?? true, notify_next_up: webhook?.notify_next_up ?? true, + notify_requests: webhook?.notify_requests ?? true, }); const pending = create.isPending || update.isPending; const editing = webhook != null; @@ -384,11 +392,14 @@ function WebhookFormDialog({
    - {REASON_FIELDS.map((field) => { + {WEBHOOK_NOTIFY_FIELDS.map((field) => { + // Requests have no per-profile reason toggle; only the master + // switch suppresses them. const globallyDisabled = globalPrefs != null && (!globalPrefs.enabled || - !(globalPrefs[field.key as keyof NotificationPreferences] as boolean)); + (field.key !== "notify_requests" && + !(globalPrefs[field.key as keyof NotificationPreferences] as boolean))); return (
    @@ -446,7 +457,7 @@ function WebhookCard({ const failing = webhook.last_failure_at != null && (webhook.last_success_at == null || webhook.last_failure_at > webhook.last_success_at); - const enabledReasons = REASON_FIELDS.filter( + const enabledReasons = WEBHOOK_NOTIFY_FIELDS.filter( (field) => webhook[field.key as keyof NotificationWebhook] as boolean, ).map((field) => field.label); @@ -468,7 +479,7 @@ function WebhookCard({
    - {enabledReasons.length === REASON_FIELDS.length + {enabledReasons.length === WEBHOOK_NOTIFY_FIELDS.length ? "All reasons" : enabledReasons.length > 0 ? enabledReasons.join(" · ") From 4274220266e979dae8ce53c9466a677660c8e05e Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:05:12 -0400 Subject: [PATCH 05/19] feat(web): show web push delivery health in notification settings Surface per-subscription delivery health on the Notifications settings page: this browser and each other device now show last-delivered / last-failed status (failures newer than the last success render in amber, mirroring webhook health), plus a device count and an explicit disabled-after-failures state. Co-Authored-By: Claude Fable 5 --- .../pages/settings/NotificationsSettings.tsx | 106 ++++++++++++++---- 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/web/src/pages/settings/NotificationsSettings.tsx b/web/src/pages/settings/NotificationsSettings.tsx index 15a6a651..33a412a6 100644 --- a/web/src/pages/settings/NotificationsSettings.tsx +++ b/web/src/pages/settings/NotificationsSettings.tsx @@ -20,6 +20,7 @@ import type { NotificationWebhook, NotificationWebhookInput, NotificationWebhookTestResult, + WebPushSubscriptionView, } from "@/api/types"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { SettingsGroup } from "@/components/settings/SettingsGroup"; @@ -140,6 +141,37 @@ function PreferencesSection() { ); } +/** + * Delivery health for one push subscription, derived the same way as webhook + * health: a failure newer than the last success means the device is failing. + */ +function webPushHealth(sub: WebPushSubscriptionView): { text: string; failing: boolean } { + if (!sub.enabled) { + return { text: "Disabled after repeated delivery failures", failing: true }; + } + const failing = + sub.last_failure_at != null && + (sub.last_success_at == null || sub.last_failure_at > sub.last_success_at); + if (failing) { + const when = formatRelativeTime(sub.last_failure_at); + return { text: when ? `Last delivery failed ${when}` : "Last delivery failed", failing: true }; + } + if (sub.last_success_at) { + const when = formatRelativeTime(sub.last_success_at); + return { text: when ? `Last delivered ${when}` : "Delivering", failing: false }; + } + return { text: "No deliveries yet", failing: false }; +} + +function webPushSubtitle(sub: WebPushSubscriptionView): { text: string; failing: boolean } { + const health = webPushHealth(sub); + const added = formatRelativeTime(sub.created_at); + return { + text: added ? `Added ${added} · ${health.text}` : health.text, + failing: health.failing, + }; +} + function WebPushSection() { const queryClient = useQueryClient(); const capability = useNotificationCapability(); @@ -155,8 +187,12 @@ function WebPushSection() { void currentWebPushSubscription().then((sub) => setThisEndpoint(sub?.endpoint ?? null)); }, []); - const subscribedHere = - thisEndpoint != null && (subscriptions ?? []).some((sub) => sub.endpoint === thisEndpoint); + const thisSub = + thisEndpoint != null + ? (subscriptions ?? []).find((sub) => sub.endpoint === thisEndpoint) + : undefined; + const subscribedHere = thisSub != null; + const thisHealth = thisSub ? webPushHealth(thisSub) : null; const enable = async () => { if (!webPushCap?.public_key) { @@ -216,8 +252,18 @@ function WebPushSection() {
    This browser
    -
    - {subscribedHere ? "Receiving notifications" : "Not receiving notifications"} +
    + {!subscribedHere + ? "Not receiving notifications" + : thisHealth?.failing + ? thisHealth.text + : thisHealth && thisHealth.text !== "No deliveries yet" + ? `Receiving notifications · ${thisHealth.text}` + : "Receiving notifications"}
    @@ -235,25 +281,41 @@ function WebPushSection() { {otherSubscriptions.length > 0 && (
    -
    Other devices
    - {otherSubscriptions.map((sub) => ( -
    -
    - - {sub.device_name || "Unknown device"} +
    + Other devices ({otherSubscriptions.length}) +
    + {otherSubscriptions.map((sub) => { + const subtitle = webPushSubtitle(sub); + return ( +
    +
    + +
    +
    {sub.device_name || "Unknown device"}
    +
    + {subtitle.text} +
    +
    +
    +
    - -
    - ))} + ); + })}
    )} From e5b210589d76faa54ec6811433f92cdf4b6fc0ed Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:16:27 -0400 Subject: [PATCH 06/19] fix(notifications): address PR #136 review findings Codex + CodeRabbit review fixes, all verified against current behavior: - Web Push: single-writer VAPID provisioning via a new conditional SetIfAbsent settings write (no split-brain identity across nodes), and read/decode failures now surface instead of silently rotating the keypair; the eager-provisioning goroutine joins the shutdown WaitGroup - Web Push: endpoint reassignment purges the previous owner's pending attempts inside the upsert transaction, with an ownership re-check at send time - Webhooks: per-profile cap enforced atomically (advisory-locked count+insert), typed pgconn unique-violation mapping, create-time type/URL mismatch rejection, send-time HTTPS re-check, and Retry-After HTTP-date support (shared, clamped parser also used by web push) - Delivery workers: transient delivery-row lookup errors leave the claim to lease expiry instead of permanently failing the attempt - Interest: history-only imports now feed the index (userstore history hooks + completed-history folding in recompute/rebuild), rebuild also recomputes existing interest rows so removed sources get cleaned up, and failed flush mutations requeue (bounded) instead of dropping - Retention: read notifications age from read_at, not created_at - Startup: scan queue workers start only after the availability detector is wired, so resumed scans cannot skip availability recording - mail: settings-store read failures propagate instead of reading as "not configured" - DB: new migration adds episode ordinal/key CHECK constraints - Web: service worker restricts notification clicks to same-origin URLs, preferences popover gets an error+retry state, and the realtime profile-rebind backoff grows to 5 minutes to keep shared channels stable through notifications-only outages Co-Authored-By: Claude Fable 5 --- cmd/silo/main.go | 14 ++- .../api/handlers/notifications_webhooks.go | 2 +- internal/catalog/encrypted_settings_repo.go | 24 ++++++ internal/catalog/server_settings_repo.go | 17 ++++ internal/mail/mail.go | 21 ++++- internal/notifications/delivery_repo.go | 7 +- internal/notifications/interest_hooks.go | 43 +++++++++- internal/notifications/interest_repo.go | 23 +++++ internal/notifications/interest_updater.go | 86 ++++++++++++++++--- internal/notifications/system.go | 34 +++++++- internal/notifications/webhook_guard.go | 2 +- internal/notifications/webhook_http.go | 36 +++++++- .../notifications/webhook_payload_discord.go | 2 +- internal/notifications/webhook_repo.go | 60 +++++++++---- internal/notifications/webhook_sender.go | 14 ++- internal/notifications/webhook_service.go | 23 ++--- internal/notifications/webhook_types.go | 7 ++ internal/notifications/webpush_repo.go | 25 +++++- internal/notifications/webpush_sender.go | 25 ++++-- internal/notifications/webpush_service.go | 69 ++++++++++----- .../20260611202951_episode_ordinal_checks.sql | 33 +++++++ web/public/sw.js | 13 ++- web/src/components/RealtimeEventsProvider.tsx | 9 +- web/src/pages/Notifications.tsx | 11 ++- 24 files changed, 507 insertions(+), 93 deletions(-) create mode 100644 migrations/sql/20260611202951_episode_ordinal_checks.sql diff --git a/cmd/silo/main.go b/cmd/silo/main.go index c0a3fbaf..3169f7b7 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -1219,8 +1219,10 @@ func main() { cfg.Scanner.MaxConcurrentLibraries, cfg.Scanner.MaxConcurrentScoped, ) - libraryScanQueue.Start() - defer libraryScanQueue.Stop() + // Started below, after the notification system has attached its + // availability detector to the executor: a scan resumed by the + // workers before that wiring would complete without recording + // episode availability, silently losing release notifications. deps.LibraryScanQueue = libraryScanQueue } if deps.DB != nil && deps.FileRepo != nil && metadataService != nil { @@ -1315,6 +1317,14 @@ func main() { } } + // Start the scan queue only now that the availability detector (when + // notifications are enabled) is attached to the ingest executor, so scans + // resumed at startup cannot complete before the detector exists. + if libraryScanQueue != nil { + libraryScanQueue.Start() + defer libraryScanQueue.Stop() + } + if userStoreProvider != nil && pluginService != nil { deps.PluginUserConfig = plugins.NewUserConfigStore(userStoreProvider, pluginService) } diff --git a/internal/api/handlers/notifications_webhooks.go b/internal/api/handlers/notifications_webhooks.go index c052d7a9..e009b114 100644 --- a/internal/api/handlers/notifications_webhooks.go +++ b/internal/api/handlers/notifications_webhooks.go @@ -179,7 +179,7 @@ func (h *NotificationsHandler) HandleDeleteWebhook(w http.ResponseWriter, r *htt } profileID := apimw.GetProfileID(r.Context()) if err := service.Delete(r.Context(), profileID, chi.URLParam(r, "id")); err != nil { - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete webhook") + writeWebhookError(w, err) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/catalog/encrypted_settings_repo.go b/internal/catalog/encrypted_settings_repo.go index 5185644f..610a1662 100644 --- a/internal/catalog/encrypted_settings_repo.go +++ b/internal/catalog/encrypted_settings_repo.go @@ -135,6 +135,30 @@ func (r *EncryptedSettingsRepo) Set(ctx context.Context, key, value string) erro return r.inner.Set(ctx, key, value) } +// settingsConditionalWriter is the optional conditional-write capability of a +// raw settings store (satisfied by *ServerSettingsRepo). +type settingsConditionalWriter interface { + SetIfAbsent(ctx context.Context, key, value string) (bool, error) +} + +// SetIfAbsent applies Set's encryption contract to a conditional write: the +// value lands only when the key currently has no value, so concurrent +// provisioners of generated secrets cannot overwrite each other. +func (r *EncryptedSettingsRepo) SetIfAbsent(ctx context.Context, key, value string) (bool, error) { + inner, ok := r.inner.(settingsConditionalWriter) + if !ok { + return false, fmt.Errorf("settings store does not support conditional writes") + } + if SensitiveSettingKeys[key] && value != "" { + ct, err := r.cipher.Encrypt(value, secret.SettingsAAD(key)) + if err != nil { + return false, fmt.Errorf("encrypt setting %q: %w", key, err) + } + value = ct + } + return inner.SetIfAbsent(ctx, key, value) +} + // Get reads a value and applies the read-path contract: legacy plaintext passes // through, an enc:v1: value is decrypted, and a corrupt ciphertext errors. func (r *EncryptedSettingsRepo) Get(ctx context.Context, key string) (string, error) { diff --git a/internal/catalog/server_settings_repo.go b/internal/catalog/server_settings_repo.go index 104032c7..0ad114f3 100644 --- a/internal/catalog/server_settings_repo.go +++ b/internal/catalog/server_settings_repo.go @@ -45,6 +45,23 @@ func (r *ServerSettingsRepo) Set(ctx context.Context, key, value string) error { return nil } +// SetIfAbsent inserts a setting only when the key has no value yet (absent or +// empty), reporting whether this call won the write. Generated credentials +// (e.g. the web push VAPID keypair) must be provisioned single-writer across +// concurrent nodes: exactly one generated value may ever land. +func (r *ServerSettingsRepo) SetIfAbsent(ctx context.Context, key, value string) (bool, error) { + tag, err := r.pool.Exec(ctx, + `INSERT INTO server_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + WHERE server_settings.value = ''`, + key, value, + ) + if err != nil { + return false, fmt.Errorf("server_settings set-if-absent %q: %w", key, err) + } + return tag.RowsAffected() > 0, nil +} + // GetAll retrieves all settings as a map. func (r *ServerSettingsRepo) GetAll(ctx context.Context) (map[string]string, error) { rows, err := r.pool.Query(ctx, `SELECT key, value FROM server_settings`) diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 3fda72d1..5f7a9a79 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -106,11 +106,22 @@ func (s *SMTPSender) loadConfig(ctx context.Context) (*smtpConfig, error) { if s == nil || s.settings == nil { return nil, ErrNotConfigured } + // A settings-store failure must surface as an error, never be mistaken + // for "email is not configured" — that would silently hide real backend + // problems behind a graceful-degradation path. + var readErr error get := func(key string) string { - value, _ := s.settings.Get(ctx, key) + value, err := s.settings.Get(ctx, key) + if err != nil && readErr == nil { + readErr = fmt.Errorf("read setting %s: %w", key, err) + } return strings.TrimSpace(value) } - if !truthy(get(SettingEnabled)) { + enabled := truthy(get(SettingEnabled)) + if readErr != nil { + return nil, readErr + } + if !enabled { return nil, ErrNotConfigured } cfg := &smtpConfig{ @@ -122,10 +133,14 @@ func (s *SMTPSender) loadConfig(ctx context.Context) (*smtpConfig, error) { fromAddress: get(SettingFromAddress), fromName: get(SettingFromName), } + portRaw := get(SettingSMTPPort) + if readErr != nil { + return nil, readErr + } if cfg.host == "" || cfg.fromAddress == "" { return nil, ErrNotConfigured } - if raw := get(SettingSMTPPort); raw != "" { + if raw := portRaw; raw != "" { port, err := strconv.Atoi(raw) if err != nil || port < 1 || port > 65535 { return nil, fmt.Errorf("invalid email.smtp_port %q", raw) diff --git a/internal/notifications/delivery_repo.go b/internal/notifications/delivery_repo.go index e5aa58b7..e114a688 100644 --- a/internal/notifications/delivery_repo.go +++ b/internal/notifications/delivery_repo.go @@ -298,12 +298,13 @@ func (r *DeliveryRepository) DeleteAllForProfile(ctx context.Context, profileID return err } -// DeleteOld applies retention: read rows past readCutoff, unread rows past -// unreadCutoff. +// DeleteOld applies retention: rows read longer ago than readCutoff, unread +// rows created before unreadCutoff. Read rows age from read_at, not +// created_at — an old notification read today starts a fresh read window. func (r *DeliveryRepository) DeleteOld(ctx context.Context, readCutoff, unreadCutoff time.Time) (int64, error) { tag, err := r.pool.Exec(ctx, ` DELETE FROM notification_deliveries - WHERE (read_at IS NOT NULL AND created_at < $1) + WHERE (read_at IS NOT NULL AND read_at < $1) OR (read_at IS NULL AND created_at < $2)`, readCutoff, unreadCutoff) if err != nil { diff --git a/internal/notifications/interest_hooks.go b/internal/notifications/interest_hooks.go index 4a793c51..6f222fe1 100644 --- a/internal/notifications/interest_hooks.go +++ b/internal/notifications/interest_hooks.go @@ -9,10 +9,11 @@ import ( ) // WrapUserStoreProvider decorates the shared user-store provider so every -// favorites, watchlist, and watch-progress mutation — regardless of which -// path performed it (REST handlers, jellycompat, history imports, playback -// stop, watch sync) — queues an interest recompute. Hooking the lowest shared -// layer keeps the seven-plus mutation call sites hook-free and drift-free. +// favorites, watchlist, watch-progress, and watch-history mutation — +// regardless of which path performed it (REST handlers, jellycompat, history +// imports, playback stop, watch sync) — queues an interest recompute. Hooking +// the lowest shared layer keeps the seven-plus mutation call sites hook-free +// and drift-free. // // Progress writes queue only on state *transitions* (a row appearing, the // in-progress flag flipping, completion crossing, rows being cleared): @@ -219,6 +220,40 @@ func (s *interestTrackingStore) ClearProgressBatch(ctx context.Context, profileI return err } +// --- Watch history: history imports and watch-provider syncs may record a +// completed watch without any progress write, so the progress hooks alone +// would never see them. AddHistory (the live playback path) is deliberately +// not hooked: playback always writes progress alongside it, and those writes +// already queue on transitions. + +func (s *interestTrackingStore) AddHistoryIfMissing(ctx context.Context, entry userstore.WatchHistoryEntry) (bool, error) { + created, err := s.UserStore.AddHistoryIfMissing(ctx, entry) + if err == nil && created && entry.Completed { + s.updater.QueueItemMutation(s.userID, entry.ProfileID, entry.MediaItemID) + } + return created, err +} + +func (s *interestTrackingStore) RemoveHistoryItems(ctx context.Context, profileID string, mediaItemIDs []string, removedAt time.Time) error { + err := s.UserStore.RemoveHistoryItems(ctx, profileID, mediaItemIDs, removedAt) + if err == nil { + for _, mediaItemID := range mediaItemIDs { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + } + return err +} + +func (s *interestTrackingStore) DeleteHistoryBySource(ctx context.Context, profileID string, mediaItemIDs []string, source userstore.WatchHistorySource) error { + err := s.UserStore.DeleteHistoryBySource(ctx, profileID, mediaItemIDs, source) + if err == nil { + for _, mediaItemID := range mediaItemIDs { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + } + return err +} + // DeleteProfile purges notification state alongside the profile itself; // profiles may live outside Postgres, so no cascade covers these tables. // The purge is best-effort: a failure is logged, never surfaced as a diff --git a/internal/notifications/interest_repo.go b/internal/notifications/interest_repo.go index 256b87f2..16f17939 100644 --- a/internal/notifications/interest_repo.go +++ b/internal/notifications/interest_repo.go @@ -121,6 +121,29 @@ func (r *InterestRepository) DeleteStaleForProfileSeries(ctx context.Context, pr return nil } +// ListSeriesForProfile returns the distinct series that currently have +// interest rows for the profile. The rebuild pass recomputes these alongside +// the series resolved from live sources so rows whose sources were removed +// while the live updater was down get cleaned up instead of lingering. +func (r *InterestRepository) ListSeriesForProfile(ctx context.Context, profileID string) ([]string, error) { + rows, err := r.pool.Query(ctx, + `SELECT DISTINCT series_id FROM profile_series_interest WHERE profile_id = $1`, + profileID) + if err != nil { + return nil, fmt.Errorf("list profile interest series: %w", err) + } + defer rows.Close() + seriesIDs := make([]string, 0, 16) + for rows.Next() { + var seriesID string + if err := rows.Scan(&seriesID); err != nil { + return nil, fmt.Errorf("scan profile interest series: %w", err) + } + seriesIDs = append(seriesIDs, seriesID) + } + return seriesIDs, rows.Err() +} + // GuardedSetLastNotified raises last_notified_episode_key for the given // profiles. The < guard makes concurrent workers handling adjacent release // events safe: the higher key wins regardless of commit order. diff --git a/internal/notifications/interest_updater.go b/internal/notifications/interest_updater.go index f2c9ba76..3811e18c 100644 --- a/internal/notifications/interest_updater.go +++ b/internal/notifications/interest_updater.go @@ -25,6 +25,10 @@ const ( interestFlushInterval = 2 * time.Second interestQueryChunk = 500 interestRecomputeTime = 30 * time.Second + // interestMaxFlushAttempts bounds requeues of a failing mutation so a + // poisoned item cannot retry every flush forever; the periodic rebuild + // task repairs whatever gets dropped. + interestMaxFlushAttempts = 5 ) type interestMutation struct { @@ -48,8 +52,10 @@ type InterestUpdater struct { scopes ScopeResolver logger *slog.Logger - mu sync.Mutex - pending map[interestMutation]struct{} + mu sync.Mutex + // pending maps each queued mutation to how many flushes have already + // failed it (transient failures requeue instead of dropping). + pending map[interestMutation]int } // NewInterestUpdater creates an InterestUpdater. @@ -65,7 +71,7 @@ func NewInterestUpdater( stores: stores, scopes: scopes, logger: slog.Default().With("component", "notifications.interest"), - pending: make(map[interestMutation]struct{}), + pending: make(map[interestMutation]int), } } @@ -77,8 +83,11 @@ func (u *InterestUpdater) QueueItemMutation(userID int, profileID, itemID string if u == nil || userID <= 0 || profileID == "" || itemID == "" { return } + mutation := interestMutation{userID: userID, profileID: profileID, itemID: itemID} u.mu.Lock() - u.pending[interestMutation{userID: userID, profileID: profileID, itemID: itemID}] = struct{}{} + if _, queued := u.pending[mutation]; !queued { + u.pending[mutation] = 0 + } u.mu.Unlock() } @@ -103,9 +112,14 @@ func (u *InterestUpdater) flush(ctx context.Context) { return } batch := u.pending - u.pending = make(map[interestMutation]struct{}) + u.pending = make(map[interestMutation]int) u.mu.Unlock() + // Transient failures requeue for the next flush instead of dropping the + // mutation, which would leave profile_series_interest stale until some + // later mutation or the rebuild task happened to touch the series. + requeue := make(map[interestMutation]int) + // Resolve items to series and dedupe to one recompute per // (user, profile, series). type recomputeKey struct { @@ -114,14 +128,15 @@ func (u *InterestUpdater) flush(ctx context.Context) { seriesID string } seen := make(map[recomputeKey]struct{}, len(batch)) - for mutation := range batch { + for mutation, failures := range batch { if ctx.Err() != nil { - return + return // shutting down; the rebuild task repairs anything dropped } seriesID, ok, err := u.resolveSeriesID(ctx, mutation.itemID) if err != nil { u.logger.Warn("interest series resolution failed", "item_id", mutation.itemID, "error", err) + requeue[mutation] = failures + 1 continue } if !ok { @@ -140,8 +155,27 @@ func (u *InterestUpdater) flush(ctx context.Context) { u.logger.Warn("interest recompute failed", "user_id", mutation.userID, "profile_id", mutation.profileID, "series_id", seriesID, "error", err) + requeue[mutation] = failures + 1 } } + + if len(requeue) == 0 { + return + } + u.mu.Lock() + for mutation, failures := range requeue { + if failures >= interestMaxFlushAttempts { + u.logger.Warn("interest mutation dropped after repeated failures", + "item_id", mutation.itemID, "profile_id", mutation.profileID) + continue + } + // A fresh queue of the same mutation (failure count 0) wins; it will + // be recomputed either way. + if _, queued := u.pending[mutation]; !queued { + u.pending[mutation] = failures + } + } + u.mu.Unlock() } // resolveSeriesID maps a media item ID (series, season, or episode) to its @@ -208,6 +242,13 @@ func (u *InterestUpdater) RecomputeSeries(ctx context.Context, userID int, profi hasProgression := false lastCompletedKey := 0 hasCompleted := false + markCompleted := func(episodeID string) { + hasProgression = true + if key, ok := episodeKeys[episodeID]; ok && (!hasCompleted || key > lastCompletedKey) { + lastCompletedKey = key + hasCompleted = true + } + } for start := 0; start < len(episodeIDs); start += interestQueryChunk { end := min(start+interestQueryChunk, len(episodeIDs)) progress, err := store.ListProgressByMediaItems(ctx, profileID, episodeIDs[start:end]) @@ -220,14 +261,37 @@ func (u *InterestUpdater) RecomputeSeries(ctx context.Context, userID int, profi continueWatching = true } if entry.Completed { - if key, ok := episodeKeys[episodeID]; ok && (!hasCompleted || key > lastCompletedKey) { - lastCompletedKey = key - hasCompleted = true - } + markCompleted(episodeID) } } } + // Completed watch history counts too: history imports and watch-provider + // syncs may record a watched-at fact without ever writing a progress row, + // and those episodes must still advance the progression cursor. + for start := 0; start < len(episodeIDs); start += interestQueryChunk { + end := min(start+interestQueryChunk, len(episodeIDs)) + chunk := episodeIDs[start:end] + for offset := 0; ; { + entries, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{ + ProfileID: profileID, + MediaItemIDs: chunk, + Limit: interestQueryChunk, + Offset: offset, + }) + if err != nil { + return fmt.Errorf("load completed history: %w", err) + } + for _, entry := range entries { + markCompleted(entry.MediaItemID) + } + if len(entries) < interestQueryChunk { + break + } + offset += len(entries) + } + } + // Conservative progression cursor: next_expected = last completed key + 1. // When the profile has gaps (completed E10 with E05 unwatched), a late- // arriving E05 will not match next_up (it may still match favorite / diff --git a/internal/notifications/system.go b/internal/notifications/system.go index dfe77961..983e3789 100644 --- a/internal/notifications/system.go +++ b/internal/notifications/system.go @@ -227,7 +227,9 @@ func (s *System) Start(ctx context.Context) { // Provision the VAPID keypair eagerly so a broken settings store // surfaces at startup instead of on the first subscribe, and the // capability endpoint never pays the generation latency. + s.wg.Add(1) go func() { + defer s.wg.Done() if _, err := s.WebPush.PublicKey(ctx); err != nil && ctx.Err() == nil { s.logger.Error("web push VAPID provisioning failed", "error", err) } @@ -419,7 +421,10 @@ func (s *System) RebuildInterest(ctx context.Context, progress func(percent int, } // rebuildProfileInterest recomputes every series the profile has any -// relationship with (favorites, watchlist, progress). +// relationship with (favorites, watchlist, progress, completed watch +// history), plus every series that already has interest rows in Postgres — +// rows whose sources were all removed must be recomputed too, or the +// drift-repair pass would keep notifying about unfollowed shows forever. func (s *System) rebuildProfileInterest(ctx context.Context, store userstore.UserStore, userID int, profileID string) error { const pageSize = 500 itemIDs := make(map[string]struct{}, 64) @@ -460,14 +465,37 @@ func (s *System) rebuildProfileInterest(ctx context.Context, store userstore.Use break } } - if len(itemIDs) == 0 { - return nil + // History imports (watch providers, history import runs) may record + // watched episodes without any progress row; they are watch relationships + // all the same. + for offset := 0; ; offset += pageSize { + history, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{ + ProfileID: profileID, + Limit: pageSize, + Offset: offset, + }) + if err != nil { + return fmt.Errorf("list completed history: %w", err) + } + for _, entry := range history { + itemIDs[entry.MediaItemID] = struct{}{} + } + if len(history) < pageSize { + break + } } seriesIDs, err := s.batchResolveSeries(ctx, itemIDs) if err != nil { return err } + existing, err := s.Interests.ListSeriesForProfile(ctx, profileID) + if err != nil { + return err + } + for _, seriesID := range existing { + seriesIDs[seriesID] = struct{}{} + } for seriesID := range seriesIDs { if ctx.Err() != nil { return ctx.Err() diff --git a/internal/notifications/webhook_guard.go b/internal/notifications/webhook_guard.go index b5bbe8b5..4b826afc 100644 --- a/internal/notifications/webhook_guard.go +++ b/internal/notifications/webhook_guard.go @@ -76,7 +76,7 @@ func ValidateWebhookURL(rawURL string, allowPrivate bool) (host string, err erro if err != nil { return "", fmt.Errorf("invalid URL") } - if parsed.Scheme != "https" { + if parsed.Scheme != schemeHTTPS { return "", fmt.Errorf("webhook URLs must use https") } host = parsed.Hostname() diff --git a/internal/notifications/webhook_http.go b/internal/notifications/webhook_http.go index a35443e4..e7f6721a 100644 --- a/internal/notifications/webhook_http.go +++ b/internal/notifications/webhook_http.go @@ -70,7 +70,7 @@ func newWebhookHTTPClient(allowPrivate func() bool) *http.Client { if len(via) >= webhookMaxRedirects { return errors.New("too many redirects") } - if req.URL.Scheme != "https" { + if req.URL.Scheme != schemeHTTPS { return errors.New("redirect to non-https destination") } return nil @@ -100,6 +100,12 @@ func sendWebhook(ctx context.Context, client *http.Client, url string, body []by if err != nil { return result(false, 0, "invalid webhook URL") } + // HTTPS is enforced at registration; re-check at the last layer before + // the wire so delivery never depends on upstream validation staying + // perfect (webhook URLs are credentials and must not travel cleartext). + if req.URL.Scheme != schemeHTTPS { + return result(false, 0, "invalid webhook URL") + } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", webhookUserAgent) for key, value := range headers { @@ -123,13 +129,35 @@ func sendWebhook(ctx context.Context, client *http.Client, url string, body []by out.Message = fmt.Sprintf("%d %s", resp.StatusCode, out.Message) } if resp.StatusCode == http.StatusTooManyRequests { - if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 { - out.RetryAfter = time.Duration(seconds) * time.Second - } + out.RetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) } return out } +// maxRetryAfter caps how far a destination's Retry-After header can push the +// next attempt; the longest scheduled backoff is 24h and a buggy or hostile +// header must not park deliveries beyond it. +const maxRetryAfter = 24 * time.Hour + +// parseRetryAfter interprets a Retry-After header in both RFC 9110 forms — +// delta-seconds and HTTP-date — returning 0 when absent or unusable. +func parseRetryAfter(header string, now time.Time) time.Duration { + header = strings.TrimSpace(header) + if header == "" { + return 0 + } + delay := time.Duration(0) + if seconds, err := strconv.Atoi(header); err == nil { + delay = time.Duration(seconds) * time.Second + } else if when, err := http.ParseTime(header); err == nil { + delay = when.Sub(now) + } + if delay <= 0 { + return 0 + } + return min(delay, maxRetryAfter) +} + // classifyWebhookError maps transport errors to short diagnostic classes. // Messages must stay free of URLs and payload contents. func classifyWebhookError(err error) string { diff --git a/internal/notifications/webhook_payload_discord.go b/internal/notifications/webhook_payload_discord.go index d1845df5..86090d0b 100644 --- a/internal/notifications/webhook_payload_discord.go +++ b/internal/notifications/webhook_payload_discord.go @@ -137,7 +137,7 @@ func discordEmbedTitle(row DeliveryRow) string { } series := row.SeriesTitle if series == "" { - series = "New episode" + series = genericEpisodeTitle } var code string if row.SeasonNumber != nil && row.EpisodeNumber != nil { diff --git a/internal/notifications/webhook_repo.go b/internal/notifications/webhook_repo.go index ef988489..95ed052c 100644 --- a/internal/notifications/webhook_repo.go +++ b/internal/notifications/webhook_repo.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) @@ -121,22 +122,47 @@ func (r *WebhookRepository) getByIDUnscoped(ctx context.Context, id string) (*We return hook, nil } -// CountByProfile returns the profile's webhook count (for the per-profile cap). -func (r *WebhookRepository) CountByProfile(ctx context.Context, profileID string) (int, error) { - var count int - err := r.pool.QueryRow(ctx, - `SELECT count(*) FROM notification_webhooks WHERE profile_id = $1`, profileID, - ).Scan(&count) - return count, err -} - // ErrWebhookNameTaken is returned when a profile already has a webhook with // the requested name. var ErrWebhookNameTaken = errors.New("a webhook with this name already exists") -// Insert persists a new webhook. -func (r *WebhookRepository) Insert(ctx context.Context, hook Webhook) error { - _, err := r.pool.Exec(ctx, ` +// isWebhookNameViolation reports whether err is the unique violation on the +// per-profile webhook name constraint, via the typed pgx error (string +// matching on error text would break if message formatting changes). +func isWebhookNameViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && + pgErr.Code == "23505" && + pgErr.ConstraintName == "notification_webhooks_profile_name_key" +} + +// InsertWithLimit persists a new webhook unless the profile is already at +// maxPerProfile. The count and insert run under a per-profile advisory +// transaction lock so concurrent creates cannot both pass the check and push +// the profile past its cap. +func (r *WebhookRepository) InsertWithLimit(ctx context.Context, hook Webhook, maxPerProfile int) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin webhook insert: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, + `SELECT pg_advisory_xact_lock(hashtextextended('notification_webhooks:' || $1, 0))`, + hook.ProfileID); err != nil { + return fmt.Errorf("lock webhook quota: %w", err) + } + var count int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM notification_webhooks WHERE profile_id = $1`, hook.ProfileID, + ).Scan(&count); err != nil { + return fmt.Errorf("count webhooks: %w", err) + } + if count >= maxPerProfile { + return ErrWebhookLimit + } + + if _, err := tx.Exec(ctx, ` INSERT INTO notification_webhooks (id, user_id, profile_id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled, @@ -146,13 +172,15 @@ func (r *WebhookRepository) Insert(ctx context.Context, hook Webhook) error { hook.ID, hook.UserID, hook.ProfileID, hook.Name, hook.Type, hook.URLCiphertext, hook.URLHost, hook.SigningSecretCiphertext, hook.Enabled, hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp, - hook.NotifyRequests) - if err != nil { - if strings.Contains(err.Error(), "notification_webhooks_profile_name_key") { + hook.NotifyRequests); err != nil { + if isWebhookNameViolation(err) { return ErrWebhookNameTaken } return fmt.Errorf("insert webhook: %w", err) } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit webhook insert: %w", err) + } return nil } @@ -175,7 +203,7 @@ func (r *WebhookRepository) Update(ctx context.Context, hook Webhook) error { hook.NotifyRequests, hook.ConsecutiveFailures, hook.DisabledReason) if err != nil { - if strings.Contains(err.Error(), "notification_webhooks_profile_name_key") { + if isWebhookNameViolation(err) { return ErrWebhookNameTaken } return fmt.Errorf("update webhook: %w", err) diff --git a/internal/notifications/webhook_sender.go b/internal/notifications/webhook_sender.go index 8a50288b..f8c12a52 100644 --- a/internal/notifications/webhook_sender.go +++ b/internal/notifications/webhook_sender.go @@ -162,7 +162,19 @@ func (s *webhookSender) processAttempt(ctx context.Context, attempt DeliveryAtte } row, err := s.deliveries.GetRowByID(ctx, attempt.NotificationDeliveryID) - if err != nil || row == nil { + if err != nil { + // Transient lookup failure: leave the claimed attempt alone so the + // lease expires and the retry worker reclaims it, instead of + // permanently failing the delivery over a database blip. + if ctx.Err() == nil { + s.logger.Warn("webhook delivery lookup failed", + "attempt_id", attempt.ID, + "delivery_id", attempt.NotificationDeliveryID, + "error", err) + } + return + } + if row == nil { _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, attempt.AttemptNumber+1, nil, "delivery row missing", nil) return diff --git a/internal/notifications/webhook_service.go b/internal/notifications/webhook_service.go index dca26874..b87787cd 100644 --- a/internal/notifications/webhook_service.go +++ b/internal/notifications/webhook_service.go @@ -90,14 +90,6 @@ func (s *WebhookService) Create(ctx context.Context, userID int, profileID strin return nil, "", err } - count, err := s.repo.CountByProfile(ctx, profileID) - if err != nil { - return nil, "", err - } - if count >= s.settings.WebhooksMaxPerProfile(ctx) { - return nil, "", ErrWebhookLimit - } - rawURL := strings.TrimSpace(*input.URL) host, err := ValidateWebhookURL(rawURL, s.settings.WebhooksAllowPrivateDestinations(ctx)) if err != nil { @@ -108,16 +100,25 @@ func (s *WebhookService) Create(ctx context.Context, userID int, profileID strin if input.Type != nil { hookType = strings.TrimSpace(*input.Type) } + isDiscordURL := discordWebhookURL(rawURL) switch hookType { case "": hookType = WebhookTypeGeneric - if discordWebhookURL(rawURL) { + if isDiscordURL { hookType = WebhookTypeDiscord } case WebhookTypeDiscord, WebhookTypeGeneric: default: return nil, "", fmt.Errorf("%w: type must be discord or generic", ErrWebhookInvalid) } + // An explicit type must match the destination, or the sender would apply + // the wrong payload/signing behavior from the first delivery. + if hookType == WebhookTypeDiscord && !isDiscordURL { + return nil, "", fmt.Errorf("%w: type discord requires a Discord webhook URL", ErrWebhookInvalid) + } + if hookType == WebhookTypeGeneric && isDiscordURL { + return nil, "", fmt.Errorf("%w: Discord webhook URLs must use type discord", ErrWebhookInvalid) + } hook := Webhook{ ID: ulid.Make().String(), @@ -151,7 +152,9 @@ func (s *WebhookService) Create(ctx context.Context, userID int, profileID strin hook.SigningSecretCiphertext = &ciphertext } - if err := s.repo.Insert(ctx, hook); err != nil { + // The per-profile cap is enforced inside the insert (advisory-locked + // count + insert), so concurrent creates cannot both slip past it. + if err := s.repo.InsertWithLimit(ctx, hook, s.settings.WebhooksMaxPerProfile(ctx)); err != nil { if errors.Is(err, ErrWebhookNameTaken) { return nil, "", fmt.Errorf("%w: %s", ErrWebhookInvalid, err.Error()) } diff --git a/internal/notifications/webhook_types.go b/internal/notifications/webhook_types.go index 9ab71d13..7cfe876a 100644 --- a/internal/notifications/webhook_types.go +++ b/internal/notifications/webhook_types.go @@ -30,6 +30,13 @@ const DeliveryTypeWebhookAutoDisabled = "webhook.auto_disabled" // build does not know (the type registry is extensible). const genericNotificationTitle = "Silo notification" +// genericEpisodeTitle is the display fallback for episode.available rows +// whose catalog metadata is missing or was deleted. +const genericEpisodeTitle = "New episode" + +// schemeHTTPS is the only scheme outbound notification HTTP traffic may use. +const schemeHTTPS = "https" + // Webhook is a profile-scoped outbound destination. URL and signing secret // are stored as enc:v1: envelopes and never leave the server. type Webhook struct { diff --git a/internal/notifications/webpush_repo.go b/internal/notifications/webpush_repo.go index ed050412..e0319b6b 100644 --- a/internal/notifications/webpush_repo.go +++ b/internal/notifications/webpush_repo.go @@ -76,7 +76,27 @@ func scanWebPushSubscriptions(rows pgx.Rows) ([]WebPushSubscription, error) { // caller's (user, profile): one browser endpoint notifies exactly one // profile, the one that subscribed most recently. func (r *WebPushRepository) Upsert(ctx context.Context, sub WebPushSubscription) (*WebPushSubscription, error) { - row := r.pool.QueryRow(ctx, ` + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin web push upsert: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Reassignment keeps the row id, so undelivered attempts enqueued for the + // previous owner would otherwise be sent to the new owner's browser once + // the retry worker reclaims them. Cancel them before the ownership flips. + if _, err := tx.Exec(ctx, ` + DELETE FROM web_push_delivery_attempts + WHERE outcome IN ('pending', 'retrying') + AND subscription_id IN ( + SELECT id FROM web_push_subscriptions + WHERE endpoint = $1 AND profile_id <> $2 + )`, + sub.Endpoint, sub.ProfileID); err != nil { + return nil, fmt.Errorf("purge reassigned web push attempts: %w", err) + } + + row := tx.QueryRow(ctx, ` INSERT INTO web_push_subscriptions (id, user_id, profile_id, endpoint, p256dh, auth, device_name) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -95,6 +115,9 @@ func (r *WebPushRepository) Upsert(ctx context.Context, sub WebPushSubscription) if err != nil { return nil, fmt.Errorf("upsert web push subscription: %w", err) } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit web push upsert: %w", err) + } return saved, nil } diff --git a/internal/notifications/webpush_sender.go b/internal/notifications/webpush_sender.go index 118bf4df..29044173 100644 --- a/internal/notifications/webpush_sender.go +++ b/internal/notifications/webpush_sender.go @@ -7,7 +7,6 @@ import ( "io" "log/slog" "net/http" - "strconv" "sync" "time" @@ -152,11 +151,29 @@ func (s *webPushSender) processAttempt(ctx context.Context, attempt DeliveryAtte return } row, err := s.deliveries.GetRowByID(ctx, attempt.NotificationDeliveryID) - if err != nil || row == nil { + if err != nil { + // Transient lookup failure: let the claim lease expire and the retry + // worker reclaim, instead of permanently failing the delivery. + if ctx.Err() == nil { + s.logger.Warn("web push delivery lookup failed", + "attempt_id", attempt.ID, + "delivery_id", attempt.NotificationDeliveryID, + "error", err) + } + return + } + if row == nil { _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, attempt.AttemptNumber+1, nil, "delivery row missing", nil) return } + if row.ProfileID != sub.ProfileID { + // The endpoint was reassigned to a different profile between enqueue + // and dispatch; this delivery belongs to the previous owner. + _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, + attempt.AttemptNumber+1, nil, "subscription reassigned", nil) + return + } publicKey, privateKey, err := s.service.vapidKeys(ctx) if err != nil { @@ -241,9 +258,7 @@ func (s *webPushSender) send(ctx context.Context, sub *WebPushSubscription, mess defer func() { _ = resp.Body.Close() }() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 16<<10)) if resp.StatusCode == http.StatusTooManyRequests { - if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 { - retryAfter = time.Duration(seconds) * time.Second - } + retryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) } return resp.StatusCode, retryAfter, nil } diff --git a/internal/notifications/webpush_service.go b/internal/notifications/webpush_service.go index 5ec0fcea..57805a46 100644 --- a/internal/notifications/webpush_service.go +++ b/internal/notifications/webpush_service.go @@ -13,10 +13,11 @@ import ( ) // VAPID key settings. The keypair is stored as a single JSON value (encrypted -// at rest via SensitiveSettingKeys) so both halves persist atomically: two -// concurrent provisioners can each lose the race wholesale but can never -// strand a mismatched public/private pair. Clients receive the public half -// from the capability endpoint, never from the settings store directly. +// at rest via SensitiveSettingKeys) so both halves persist atomically, and it +// is written with SetIfAbsent so exactly one concurrent provisioner's pair can +// ever land — a mismatched public/private pair or split-brain identity across +// nodes is impossible. Clients receive the public half from the capability +// endpoint, never from the settings store directly. const ( SettingWebPushEnabled = "notifications.web_push_enabled" SettingWebPushVAPIDKeypair = "notifications.web_push.vapid_keypair" //nolint:gosec // setting key name, not a credential @@ -37,6 +38,10 @@ func (s *Settings) WebPushEnabled(ctx context.Context) bool { // catalog.EncryptedSettingsRepo, which encrypts sensitive keys on write. type SettingWriter interface { Set(ctx context.Context, key, value string) error + // SetIfAbsent writes only when the key has no value yet, reporting whether + // this writer won. Generated credentials must be provisioned single-writer: + // concurrent nodes may race to generate, but exactly one value can land. + SetIfAbsent(ctx context.Context, key, value string) (bool, error) } // ErrWebPushInvalid marks rejected subscription input. @@ -70,8 +75,13 @@ func (s *WebPushService) vapidKeys(ctx context.Context) (publicKey, privateKey s return s.publicKey, s.privateKey, nil } - stored := s.loadKeypair(ctx) - if stored.Public == "" || stored.Private == "" { + stored, found, err := s.loadKeypair(ctx) + if err != nil { + // Never reprovision over a read or decode failure: rotating the VAPID + // identity silently invalidates every existing browser subscription. + return "", "", err + } + if !found { if s.writer == nil { return "", "", errors.New("web push requires a writable settings store") } @@ -83,15 +93,24 @@ func (s *WebPushService) vapidKeys(ctx context.Context) (publicKey, privateKey s if marshalErr != nil { return "", "", fmt.Errorf("encode VAPID keypair: %w", marshalErr) } - if err := s.writer.Set(ctx, SettingWebPushVAPIDKeypair, string(data)); err != nil { - return "", "", fmt.Errorf("persist VAPID keypair: %w", err) + // Conditional write: with concurrent provisioners exactly one generated + // pair can ever land, so no node can cache a pair another node's write + // later overwrites (split-brain VAPID identities). + won, setErr := s.writer.SetIfAbsent(ctx, SettingWebPushVAPIDKeypair, string(data)) + if setErr != nil { + return "", "", fmt.Errorf("persist VAPID keypair: %w", setErr) } - // Re-read and adopt whatever the store now holds: if another process - // provisioned concurrently, the last write won and every node must - // converge on that pair rather than caching its own loser. - stored = s.loadKeypair(ctx) - if stored.Public == "" || stored.Private == "" { + if won { stored = vapidKeypair{Public: public, Private: private} + } else { + // Another node provisioned first: adopt its pair. + stored, found, err = s.loadKeypair(ctx) + if err != nil { + return "", "", err + } + if !found { + return "", "", errors.New("VAPID keypair disappeared during provisioning") + } } } s.publicKey = stored.Public @@ -101,21 +120,27 @@ func (s *WebPushService) vapidKeys(ctx context.Context) (publicKey, privateKey s // loadKeypair reads the persisted keypair directly from the settings reader, // bypassing the Settings facade cache: provisioning must observe the latest -// stored value, not a seconds-old cached miss. A corrupt value reads as -// unprovisioned and is overwritten with a fresh pair. -func (s *WebPushService) loadKeypair(ctx context.Context) vapidKeypair { - var keys vapidKeypair +// stored value, not a seconds-old cached miss. found is true only for a +// complete stored pair; read and decode failures surface as errors so callers +// never mistake them for "not provisioned yet". +func (s *WebPushService) loadKeypair(ctx context.Context) (keys vapidKeypair, found bool, err error) { if s.settings == nil || s.settings.reader == nil { - return keys + return vapidKeypair{}, false, nil } raw, err := s.settings.reader.Get(ctx, SettingWebPushVAPIDKeypair) - if err != nil || strings.TrimSpace(raw) == "" { - return vapidKeypair{} + if err != nil { + return vapidKeypair{}, false, fmt.Errorf("read VAPID keypair: %w", err) + } + if strings.TrimSpace(raw) == "" { + return vapidKeypair{}, false, nil } if err := json.Unmarshal([]byte(raw), &keys); err != nil { - return vapidKeypair{} + return vapidKeypair{}, false, fmt.Errorf("decode stored VAPID keypair: %w", err) } - return keys + if keys.Public == "" || keys.Private == "" { + return vapidKeypair{}, false, errors.New("stored VAPID keypair is incomplete") + } + return keys, true, nil } // PublicKey returns the VAPID application server key clients subscribe with. diff --git a/migrations/sql/20260611202951_episode_ordinal_checks.sql b/migrations/sql/20260611202951_episode_ordinal_checks.sql new file mode 100644 index 00000000..46f31fb3 --- /dev/null +++ b/migrations/sql/20260611202951_episode_ordinal_checks.sql @@ -0,0 +1,33 @@ +-- +goose Up +-- +goose StatementBegin +-- Enforce the episode-key invariants (episode_key.go: EpisodeKey / +-- ValidEpisodeOrdinals) at the database layer too, so direct SQL or future +-- backfill paths cannot insert rows that break the collision-free ordering +-- contract the fanout and suppression logic depend on. Bounds mirror the Go +-- constants: season <= 2146 keeps season * 1,000,000 + episode inside int4. +ALTER TABLE public.episode_availability + ADD CONSTRAINT episode_availability_ordinals_check CHECK ( + season_number >= 0 + AND season_number <= 2146 + AND episode_number >= 0 + AND episode_number < 1000000 + AND episode_key = season_number * 1000000 + episode_number + ); + +ALTER TABLE public.release_events + ADD CONSTRAINT release_events_ordinals_check CHECK ( + season_number >= 0 + AND season_number <= 2146 + AND episode_number >= 0 + AND episode_number < 1000000 + AND episode_key = season_number * 1000000 + episode_number + ); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE public.episode_availability + DROP CONSTRAINT IF EXISTS episode_availability_ordinals_check; +ALTER TABLE public.release_events + DROP CONSTRAINT IF EXISTS release_events_ordinals_check; +-- +goose StatementEnd diff --git a/web/public/sw.js b/web/public/sw.js index a20dda06..e20a4489 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -32,7 +32,18 @@ self.addEventListener("push", (event) => { self.addEventListener("notificationclick", (event) => { event.notification.close(); - const url = (event.notification.data && event.notification.data.url) || "/notifications"; + // Notifications navigate same-origin only: payload data is server-built, + // but a notification surface must never become an open redirect. + const rawUrl = (event.notification.data && event.notification.data.url) || "/notifications"; + let url = "/notifications"; + try { + const parsed = new URL(rawUrl, self.location.origin); + if (parsed.origin === self.location.origin) { + url = `${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } catch { + // keep the safe default + } event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { for (const client of clientList) { diff --git a/web/src/components/RealtimeEventsProvider.tsx b/web/src/components/RealtimeEventsProvider.tsx index 1f11b95b..a75d5d9e 100644 --- a/web/src/components/RealtimeEventsProvider.tsx +++ b/web/src/components/RealtimeEventsProvider.tsx @@ -739,10 +739,15 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { (entry) => entry.channel === "notifications" && entry.code === "profile_required", ); if (profileRequired && activeProfileIDRef.current) { + // Rebinding requires a fresh handshake (tickets are consumed at + // upgrade time), so the shared socket must close. The backoff + // grows to 5 minutes so a persistent notifications-only outage + // costs the catalog/user_state channels one brief flap per + // cycle instead of a permanent fast reconnect loop. profileRebindAttemptsRef.current += 1; nextReconnectDelayRef.current = Math.min( - 30_000, - 1_000 * 2 ** Math.min(profileRebindAttemptsRef.current, 5), + 300_000, + 1_000 * 2 ** Math.min(profileRebindAttemptsRef.current, 9), ); socket.close(); } else { diff --git a/web/src/pages/Notifications.tsx b/web/src/pages/Notifications.tsx index 84971e88..efc95b4d 100644 --- a/web/src/pages/Notifications.tsx +++ b/web/src/pages/Notifications.tsx @@ -203,7 +203,7 @@ function NotificationRow({ } function NotificationPreferencesPopover() { - const { data: prefs, isLoading } = useNotificationPreferences(); + const { data: prefs, isLoading, refetch } = useNotificationPreferences(); const updatePrefs = useUpdateNotificationPreferences(); const toggles: Array<{ @@ -252,12 +252,19 @@ function NotificationPreferencesPopover() { - {isLoading || !prefs ? ( + {isLoading ? (
    + ) : !prefs ? ( +
    +

    Couldn’t load preferences.

    + +
    ) : (
    {toggles.map((toggle, index) => ( From df95e3cb9516f565ee0afaff87ec32d38b0fd18f Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:09:37 -0400 Subject: [PATCH 07/19] feat(notifications): email notification channel Adds email as a notification channel built on the shared SMTP core (mail.Sender). Email mode is a per-account preference (off, daily digest, or per-episode) stored in notification_email_prefs; delivery is an account-watermark sweep over notification_deliveries that dedupes cross-profile duplicates, advancing the watermark only after a successful send. Admin controls cover the channel kill switch, the per-episode allowance (off coerces those accounts to the digest), digest hour, and an external URL for deep links inside emails. Availability is advertised through /notifications/capability and the user settings page gains an Email section for opt-in. Co-Authored-By: Claude Fable 5 --- cmd/silo/main.go | 2 + .../plans/notifications/07-email-channel.md | 109 ++++++ internal/api/handlers/notifications.go | 22 ++ internal/api/handlers/notifications_email.go | 62 +++ internal/api/router.go | 2 + internal/notifications/delivery_repo.go | 16 + internal/notifications/email_compose.go | 304 +++++++++++++++ internal/notifications/email_digest.go | 360 ++++++++++++++++++ internal/notifications/email_logic_test.go | 191 ++++++++++ internal/notifications/email_prefs_repo.go | 210 ++++++++++ internal/notifications/settings.go | 32 ++ internal/notifications/system.go | 30 +- ...60611201720_email_notification_channel.sql | 34 ++ web/src/api/types.ts | 8 + web/src/hooks/queries/keys.ts | 1 + web/src/hooks/queries/notifications.ts | 26 ++ .../NotificationsAdminSettings.tsx | 35 ++ .../pages/settings/NotificationsSettings.tsx | 97 +++++ 18 files changed, 1540 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/notifications/07-email-channel.md create mode 100644 internal/api/handlers/notifications_email.go create mode 100644 internal/notifications/email_compose.go create mode 100644 internal/notifications/email_digest.go create mode 100644 internal/notifications/email_logic_test.go create mode 100644 internal/notifications/email_prefs_repo.go create mode 100644 migrations/sql/20260611201720_email_notification_channel.sql diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 3169f7b7..cafcc1b9 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -56,6 +56,7 @@ import ( "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/logfilter" "github.com/Silo-Server/silo-server/internal/logstream" + "github.com/Silo-Server/silo-server/internal/mail" "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/metadata" @@ -1305,6 +1306,7 @@ func main() { deps.EventsHub, deps.RedisClient, deps.SecretCipher, + mail.NewSMTPSender(settingsRepo), ) userStoreProvider = notifications.WrapUserStoreProvider(userStoreProvider, notificationSystem) deps.Notifications = notificationSystem diff --git a/docs/superpowers/plans/notifications/07-email-channel.md b/docs/superpowers/plans/notifications/07-email-channel.md new file mode 100644 index 00000000..42372399 --- /dev/null +++ b/docs/superpowers/plans/notifications/07-email-channel.md @@ -0,0 +1,109 @@ +# Notifications: Email Channel + +**Date:** 2026-06-11 +**Status:** Implemented (written post-implementation) +**Scope:** Item 3 of [`06-v1.5-roadmap.md`](./06-v1.5-roadmap.md) — the first real consumer of the shared SMTP core (`internal/mail`, `docs/architecture/email.md`). +**Depends On:** [`00-architecture-overview.md`](./00-architecture-overview.md), [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) + +## Decisions (resolving the roadmap's open questions) + +- **Account-level, as planned.** Email addresses live on `users`; one mode + covers every profile on the account and one email aggregates across them. +- **Per-episode AND digest, not digest-only.** The roadmap recommended + digest-only; product direction chose to offer per-episode alerts too, gated + by an admin allowance (`notifications.email.allow_per_episode`). When the + admin disallows it, accounts set to per-episode are **coerced to the daily + digest** rather than silenced. +- **Interest-scoped by construction.** Email consumes existing + `notification_deliveries` rows, which the fanout only creates for profiles + with series interest (favorites, watchlist, continue-watching, next-up) and + for direct notices (`request.fulfilled`, `webhook.auto_disabled`). The + channel adds no targeting of its own — it is never "all new content". +- **Opt-in, default off.** Users enable it per account in Settings → + Notifications; enabling initializes the watermark to now so history never + floods a fresh opt-in. + +## Architecture: watermark sweep, not a third outbox + +Webhooks and web push use per-target outbox attempt rows. Email deliberately +does not: + +- Deliveries already carry `user_id`, and an account whose profiles follow the + same series gets one row per profile — a per-row outbox would email the same + episode several times. The sweep collapses them (dedupe by `episode_id`, by + `request_id` for requests). +- A per-account watermark over `(created_at, id)` that advances **only after a + successful SMTP send** gives durability for free: a crash or SMTP outage + re-sends on the next pass instead of dropping. +- Both cadences are the same mechanism: per-episode sweeps every minute (and + is nudged by the dispatcher seconds after fanout commits); the digest is the + same sweep gated on "today's send hour passed and not yet stamped today". + +State lives in `notification_email_prefs` (`migrations/sql/`, +`email_notification_channel`): mode, watermark, `last_digest_at`, and failure +backoff counters (`last_attempt_at`, `consecutive_failures`; 1m doubling, +capped at 6h). No FK to `users` per the notification-tables rule; deleted or +disabled accounts drop out of the recipient join. A supporting index +`notification_deliveries_user_created_idx (user_id, created_at, id)` serves +the sweep. + +**Multi-node safety:** each account is processed inside one transaction that +claims the prefs row `FOR UPDATE SKIP LOCKED`, re-derives eligibility from the +locked row (mode flips and another node's digest stamp are both re-checked), +sends, then commits the watermark/stamp. Failed sends commit only the backoff +counters. `mail.ErrNotConfigured` aborts the whole pass; three consecutive +send failures end it early (SMTP trouble is global, not per-recipient). + +**Flood bounds:** one email renders at most 30 lines (`…and N more in your +Silo inbox`), one pass fetches at most 200 rows per account, and upstream the +per-series burst cap already limits fanout volume. Digest emails include only +rows still unread at compose time; the watermark passes read rows silently. + +## Files + +| Piece | Location | +|---|---| +| Modes, prefs repo (`notification_email_prefs`) | `internal/notifications/email_prefs_repo.go` | +| Worker, dispatcher nudge, System service methods | `internal/notifications/email_digest.go` | +| Subject/text/HTML rendering | `internal/notifications/email_compose.go` | +| Account sweep query | `DeliveryRepository.ListForUserSince` (`internal/notifications/delivery_repo.go`) | +| Settings accessors | `internal/notifications/settings.go` | +| API handlers | `internal/api/handlers/notifications_email.go` (+ capability in `notifications.go`) | +| Web UI (user) | `EmailSection` in `web/src/pages/settings/NotificationsSettings.tsx` | +| Web UI (admin) | Email group in `web/src/pages/admin-settings/NotificationsAdminSettings.tsx` | +| Logic tests | `internal/notifications/email_logic_test.go` | + +The worker is wired in `notifications.NewSystem` (new `mail.Sender` parameter, +passed from `cmd/silo/main.go`); its dispatcher joins the `MultiDispatcher`, +so operational deliveries (`request.fulfilled`, `webhook.auto_disabled`) nudge +it exactly like fanout rows do. + +## Settings + +| Key | Default | Meaning | +|---|---|---| +| `notifications.email_enabled` | `true` | Channel kill switch (availability still requires SMTP configured via `email.*`) | +| `notifications.email.allow_per_episode` | `true` | Admin allowance for the per-episode cadence | +| `notifications.email.digest_hour` | `8` | Hour (0–23, server-local) daily digests go out | +| `notifications.email.external_url` | empty | Public base URL for deep links in emails; empty sends link-free emails (the server origin is never leaked implicitly) | + +## API + +- `GET /api/v1/notifications/email-preferences` → `{"mode": "off" | "per_episode" | "daily_digest"}` +- `PUT /api/v1/notifications/email-preferences` `{"mode": ...}` — 400 codes: + `bad_request` (unknown mode), `not_allowed` (per-episode disallowed), + `no_email` (account has no address). Any profile on the account may set it. +- `GET /api/v1/notifications/capability` gained + `"email": {"available", "modes", "digest_hour"}`; clients gate setup UI on + it as usual. `available` requires the kill switch on **and** + `mail.Sender.Enabled()` — never read `email.*` settings directly. + +## Deliberately not in v1 + +- Posters/images in emails (would require externally reachable presigned URLs). +- `List-Unsubscribe` headers / tokenized unsubscribe endpoint (self-hosted, + opt-in; revisit if servers grow beyond household scale). +- Per-user digest hour (admin-global for now). +- DB-backed integration tests for the sweep (same Postgres-harness gap as the + rest of `01`'s verification backlog; pure logic is covered by + `email_logic_test.go`). diff --git a/internal/api/handlers/notifications.go b/internal/api/handlers/notifications.go index 545ba7c9..6c9279e3 100644 --- a/internal/api/handlers/notifications.go +++ b/internal/api/handlers/notifications.go @@ -282,6 +282,15 @@ type capabilityResponse struct { AndroidPush capabilityPush `json:"android_push"` WebPush capabilityWebPush `json:"web_push"` Webhooks capabilityWebhooks `json:"webhooks"` + Email capabilityEmail `json:"email"` +} + +type capabilityEmail struct { + Available bool `json:"available"` + // Modes lists the cadences users may pick (per-episode is an admin + // allowance); DigestHour tells the UI when daily digests go out. + Modes []string `json:"modes"` + DigestHour int `json:"digest_hour"` } type capabilityWebPush struct { @@ -324,12 +333,25 @@ func (h *NotificationsHandler) HandleCapability(w http.ResponseWriter, r *http.R webPush = capabilityWebPush{Available: true, PublicKey: publicKey} } } + email := capabilityEmail{Modes: []string{}} + if h.system.EmailAvailable(r.Context()) { + modes := []string{notifications.EmailModeDailyDigest} + if h.system.Settings.EmailAllowPerEpisode(r.Context()) { + modes = append(modes, notifications.EmailModePerEpisode) + } + email = capabilityEmail{ + Available: true, + Modes: modes, + DigestHour: h.system.Settings.EmailDigestHour(r.Context()), + } + } writeJSON(w, http.StatusOK, capabilityResponse{ InApp: capabilityInApp{Enabled: h.system.Settings.UIEnabled(r.Context())}, ApplePush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}, AndroidPush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}, WebPush: webPush, Webhooks: webhooks, + Email: email, }) } diff --git a/internal/api/handlers/notifications_email.go b/internal/api/handlers/notifications_email.go new file mode 100644 index 00000000..64dcb9e3 --- /dev/null +++ b/internal/api/handlers/notifications_email.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/notifications" +) + +// emailPreferencesResponse is the account-level email notification setting. +// Unlike the per-profile preferences, one mode covers every profile on the +// login account: email addresses live on accounts, and the emails themselves +// aggregate across profiles. +type emailPreferencesResponse struct { + Mode string `json:"mode"` +} + +type updateEmailPreferencesRequest struct { + Mode string `json:"mode"` +} + +// HandleGetEmailPreferences handles GET /notifications/email-preferences. +func (h *NotificationsHandler) HandleGetEmailPreferences(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + mode, err := h.system.EmailMode(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load email preferences") + return + } + writeJSON(w, http.StatusOK, emailPreferencesResponse{Mode: mode}) +} + +// HandleUpdateEmailPreferences handles PUT /notifications/email-preferences. +func (h *NotificationsHandler) HandleUpdateEmailPreferences(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + + var req updateEmailPreferencesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + err := h.system.SetEmailMode(r.Context(), userID, req.Mode) + switch { + case err == nil: + case errors.Is(err, notifications.ErrEmailModeInvalid): + writeError(w, http.StatusBadRequest, "bad_request", "Unknown email notification mode") + return + case errors.Is(err, notifications.ErrEmailModeNotAllowed): + writeError(w, http.StatusBadRequest, "not_allowed", "Per-episode email is disabled by the administrator") + return + case errors.Is(err, notifications.ErrEmailNoAddress): + writeError(w, http.StatusBadRequest, "no_email", "Your account has no email address") + return + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save email preferences") + return + } + writeJSON(w, http.StatusOK, emailPreferencesResponse{Mode: req.Mode}) +} diff --git a/internal/api/router.go b/internal/api/router.go index b6ccff10..d263213c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1544,6 +1544,8 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/capability", notificationsHandler.HandleCapability) r.Get("/preferences", notificationsHandler.HandleGetPreferences) r.Put("/preferences", notificationsHandler.HandleUpdatePreferences) + r.Get("/email-preferences", notificationsHandler.HandleGetEmailPreferences) + r.Put("/email-preferences", notificationsHandler.HandleUpdateEmailPreferences) r.Post("/read-all", notificationsHandler.HandleReadAll) r.Route("/webhooks", func(r chi.Router) { r.Get("/", notificationsHandler.HandleListWebhooks) diff --git a/internal/notifications/delivery_repo.go b/internal/notifications/delivery_repo.go index e114a688..cc545d9e 100644 --- a/internal/notifications/delivery_repo.go +++ b/internal/notifications/delivery_repo.go @@ -228,6 +228,22 @@ func (r *DeliveryRepository) GetRowByID(ctx context.Context, id string) (*Delive return &out[0], nil } +// ListForUserSince returns the account's deliveries newer than the watermark, +// ascending, across all of its profiles. Runs inside the email worker's claim +// transaction so the rows read are the rows the advanced watermark covers. +func (r *DeliveryRepository) ListForUserSince(ctx context.Context, tx pgx.Tx, userID int, since Cursor, limit int) ([]DeliveryRow, error) { + rows, err := tx.Query(ctx, + deliveryRowSelect+` + WHERE d.user_id = $1 AND (d.created_at, d.id) > ($2, $3) + ORDER BY d.created_at ASC, d.id ASC + LIMIT $4`, + userID, since.CreatedAt, since.ID, limit) + if err != nil { + return nil, fmt.Errorf("list user deliveries since: %w", err) + } + return scanDeliveryRows(rows) +} + // RecentUnread returns the newest unread rows for the websocket snapshot. func (r *DeliveryRepository) RecentUnread(ctx context.Context, profileID string, limit int) ([]DeliveryRow, error) { rows, err := r.pool.Query(ctx, diff --git a/internal/notifications/email_compose.go b/internal/notifications/email_compose.go new file mode 100644 index 00000000..d3cd18e3 --- /dev/null +++ b/internal/notifications/email_compose.go @@ -0,0 +1,304 @@ +package notifications + +import ( + "fmt" + "html" + "sort" + "strings" +) + +// emailMaxItemsRendered caps how many lines one email renders; the remainder +// collapses into a "+N more" line (the inbox always has everything). +const emailMaxItemsRendered = 30 + +// untitledSeriesGroup is the group heading for episode rows whose series +// metadata is missing or was deleted. +const untitledSeriesGroup = "New episodes" + +// emailContent is one rendered notification email. +type emailContent struct { + Subject string + Text string + HTML string +} + +// emailSeriesGroup is one series' new episodes, in first-appearance order. +type emailSeriesGroup struct { + seriesID string + title string + episodes []DeliveryRow +} + +// emailItems is the collated, deduplicated content of one email. Accounts +// with several profiles following the same series have one delivery row per +// profile; an email reports the episode once. +type emailItems struct { + series []emailSeriesGroup + episodes int + requests []DeliveryRow + others []DeliveryRow +} + +// collateEmailItems groups and dedupes delivery rows for rendering. +func collateEmailItems(rows []DeliveryRow) emailItems { + var items emailItems + seenEpisodes := make(map[string]struct{}, len(rows)) + seenRequests := make(map[string]struct{}, 4) + groupIndex := make(map[string]int, 4) + + for _, row := range rows { + switch row.Type { + case DeliveryTypeEpisodeAvailable: + key := row.ID + if row.EpisodeID != nil && *row.EpisodeID != "" { + key = *row.EpisodeID + } + if _, ok := seenEpisodes[key]; ok { + continue + } + seenEpisodes[key] = struct{}{} + seriesID := "" + if row.SeriesID != nil { + seriesID = *row.SeriesID + } + idx, ok := groupIndex[seriesID] + if !ok { + idx = len(items.series) + groupIndex[seriesID] = idx + title := row.SeriesTitle + if title == "" { + title = untitledSeriesGroup + } + items.series = append(items.series, emailSeriesGroup{seriesID: seriesID, title: title}) + } + items.series[idx].episodes = append(items.series[idx].episodes, row) + items.episodes++ + case DeliveryTypeRequestFulfilled: + key := parseRequestFulfilledFlags(row.ReasonFlags).RequestID + if key == "" { + key = row.ID + } + if _, ok := seenRequests[key]; ok { + continue + } + seenRequests[key] = struct{}{} + items.requests = append(items.requests, row) + default: + items.others = append(items.others, row) + } + } + + for i := range items.series { + sort.SliceStable(items.series[i].episodes, func(a, b int) bool { + ea, eb := items.series[i].episodes[a], items.series[i].episodes[b] + if ea.SeasonNumber == nil || eb.SeasonNumber == nil || + ea.EpisodeNumber == nil || eb.EpisodeNumber == nil { + return false + } + if *ea.SeasonNumber != *eb.SeasonNumber { + return *ea.SeasonNumber < *eb.SeasonNumber + } + return *ea.EpisodeNumber < *eb.EpisodeNumber + }) + } + return items +} + +// episodeCode renders "S02E03"; empty when numbering is unknown. +func episodeCode(row DeliveryRow) string { + if row.SeasonNumber == nil || row.EpisodeNumber == nil { + return "" + } + return fmt.Sprintf("S%02dE%02d", *row.SeasonNumber, *row.EpisodeNumber) +} + +// episodeLine renders one episode entry: "S02E03 — Title", degrading to +// whichever part exists. +func episodeLine(row DeliveryRow) string { + code := episodeCode(row) + switch { + case code != "" && row.EpisodeTitle != "": + return code + " — " + row.EpisodeTitle + case code != "": + return code + case row.EpisodeTitle != "": + return row.EpisodeTitle + default: + return genericEpisodeTitle + } +} + +// requestLine renders one fulfilled-request entry. +func requestLine(row DeliveryRow) string { + if row.SeriesTitle != "" { + return row.SeriesTitle + " is now available" + } + return "Your media request is now available" +} + +// otherLine renders operational and unknown delivery types generically. +func otherLine(row DeliveryRow) string { + if row.Type == DeliveryTypeWebhookAutoDisabled { + return "A webhook stopped working — open notification settings to fix it" + } + return genericNotificationTitle +} + +// countPart pluralizes "3 new episodes" style subject fragments. +func countPart(count int, singular, plural string) string { + if count == 1 { + return "1 " + singular + } + return fmt.Sprintf("%d %s", count, plural) +} + +// emailSubject builds the subject line from the collated items. +func emailSubject(mode string, items emailItems) string { + parts := make([]string, 0, 3) + if items.episodes > 0 { + parts = append(parts, countPart(items.episodes, "new episode", "new episodes")) + } + if len(items.requests) > 0 { + parts = append(parts, countPart(len(items.requests), "request ready", "requests ready")) + } + if len(items.others) > 0 { + parts = append(parts, countPart(len(items.others), "update", "updates")) + } + summary := strings.Join(parts, ", ") + + if mode == EmailModeDailyDigest { + return "Silo daily digest: " + summary + } + if items.episodes == 1 && len(items.requests) == 0 && len(items.others) == 0 { + row := items.series[0].episodes[0] + subject := genericEpisodeTitle + if items.series[0].title != untitledSeriesGroup { + subject += " of " + items.series[0].title + } + if line := episodeLine(row); line != genericEpisodeTitle { + subject += ": " + line + } + return subject + } + if items.episodes == 0 && len(items.requests) == 1 && len(items.others) == 0 { + return requestLine(items.requests[0]) + } + return "Silo: " + summary +} + +// itemURL builds a deep link; empty when no external URL is configured. +func itemURL(baseURL, itemID string) string { + if baseURL == "" || itemID == "" { + return "" + } + return baseURL + "/item/" + itemID +} + +// composeNotificationEmail renders one email (text + HTML) for the given +// delivery rows. baseURL is the admin-configured external URL; empty renders +// without links. +func composeNotificationEmail(mode string, rows []DeliveryRow, baseURL string) emailContent { + items := collateEmailItems(rows) + + var text strings.Builder + var body strings.Builder + rendered := 0 + total := items.episodes + len(items.requests) + len(items.others) + + writeLine := func(plain, href string) { + rendered++ + if rendered > emailMaxItemsRendered { + return + } + text.WriteString(" " + plain + "\n") + if href != "" { + body.WriteString(fmt.Sprintf( + `
  • %s
  • `, + html.EscapeString(href), html.EscapeString(plain))) + } else { + body.WriteString(fmt.Sprintf(`
  • %s
  • `, html.EscapeString(plain))) + } + } + writeHeading := func(title, href string) { + text.WriteString(title + "\n") + label := html.EscapeString(title) + if href != "" { + label = fmt.Sprintf(`%s`, + html.EscapeString(href), label) + } + body.WriteString(fmt.Sprintf( + `

    %s

    `, label)) + } + openList := func() { body.WriteString(`
      `) } + closeList := func() { body.WriteString(`
    `) } + + for _, group := range items.series { + if rendered >= emailMaxItemsRendered { + break + } + writeHeading(group.title, itemURL(baseURL, group.seriesID)) + openList() + for _, row := range group.episodes { + episodeID := "" + if row.EpisodeID != nil { + episodeID = *row.EpisodeID + } + writeLine(episodeLine(row), itemURL(baseURL, episodeID)) + } + closeList() + } + if len(items.requests) > 0 && rendered < emailMaxItemsRendered { + writeHeading("Requests ready", "") + openList() + for _, row := range items.requests { + seriesID := "" + if row.SeriesID != nil { + seriesID = *row.SeriesID + } + writeLine(requestLine(row), itemURL(baseURL, seriesID)) + } + closeList() + } + if len(items.others) > 0 && rendered < emailMaxItemsRendered { + writeHeading("Other updates", "") + openList() + for _, row := range items.others { + writeLine(otherLine(row), "") + } + closeList() + } + if remainder := total - emailMaxItemsRendered; remainder > 0 { + more := fmt.Sprintf("…and %d more in your Silo inbox.", remainder) + text.WriteString(more + "\n") + body.WriteString(fmt.Sprintf( + `

    %s

    `, html.EscapeString(more))) + } + + intro := "New in your library:" + if mode == EmailModeDailyDigest { + intro = "Here's what's new since your last digest:" + } + footer := "You're receiving this because email notifications are enabled for your Silo account. " + + "Manage them in Settings → Notifications." + footerHTML := html.EscapeString(footer) + if baseURL != "" { + settingsURL := html.EscapeString(baseURL + "/settings/notifications") + footerHTML = strings.Replace(footerHTML, + "Settings → Notifications", + fmt.Sprintf(`Settings → Notifications`, settingsURL), 1) + } + + htmlBody := fmt.Sprintf(`
    +

    %s

    +%s +
    +

    %s

    +
    `, + html.EscapeString(intro), body.String(), footerHTML) + + return emailContent{ + Subject: emailSubject(mode, items), + Text: intro + "\n\n" + text.String() + "\n" + footer + "\n", + HTML: htmlBody, + } +} diff --git a/internal/notifications/email_digest.go b/internal/notifications/email_digest.go new file mode 100644 index 00000000..110cd817 --- /dev/null +++ b/internal/notifications/email_digest.go @@ -0,0 +1,360 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/Silo-Server/silo-server/internal/mail" + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + emailPollInterval = time.Minute + // emailNudgeDelay coalesces the per-row dispatch nudges of one fanout + // batch (all rows commit before the first nudge fires) into one pass. + emailNudgeDelay = 2 * time.Second + // emailFetchLimit bounds one email's worth of watermark progress; the + // next pass drains the remainder. + emailFetchLimit = 200 + // emailMaxFailuresPerPass stops a pass early when sends keep failing — + // SMTP trouble is almost always global, not per-recipient. + emailMaxFailuresPerPass = 3 + + emailFailureBackoffBase = time.Minute + emailFailureBackoffMax = 6 * time.Hour +) + +// effectiveEmailMode coerces per-episode to the daily digest when the admin +// has disallowed per-episode email, instead of silencing those accounts. +func effectiveEmailMode(mode string, allowPerEpisode bool) string { + if mode == EmailModePerEpisode && !allowPerEpisode { + return EmailModeDailyDigest + } + return mode +} + +// emailDigestDue reports whether a daily digest should go out: today's send +// time (digestHour, local) has passed and no digest was stamped since. +func emailDigestDue(now time.Time, digestHour int, lastDigestAt *time.Time) bool { + todaySend := time.Date(now.Year(), now.Month(), now.Day(), digestHour, 0, 0, 0, now.Location()) + if now.Before(todaySend) { + return false + } + return lastDigestAt == nil || lastDigestAt.Before(todaySend) +} + +// emailRetryEligible applies exponential backoff after failed sends: +// 1m, 2m, 4m, ... capped at emailFailureBackoffMax. +func emailRetryEligible(now time.Time, lastAttemptAt *time.Time, consecutiveFailures int) bool { + if consecutiveFailures <= 0 || lastAttemptAt == nil { + return true + } + backoff := emailFailureBackoffBase << min(consecutiveFailures-1, 30) + if backoff > emailFailureBackoffMax || backoff <= 0 { + backoff = emailFailureBackoffMax + } + return !now.Before(lastAttemptAt.Add(backoff)) +} + +// EmailWorker delivers notification emails. Unlike webhooks and web push it +// keeps no per-target outbox: deliveries already carry user_id, so a per-user +// watermark over notification_deliveries is the durable dispatch state. The +// watermark advances only after a successful SMTP send, and one email covers +// everything since the last one — which also collapses the duplicate rows an +// account gets when several of its profiles follow the same series. +type EmailWorker struct { + pool *pgxpool.Pool + deliveries *DeliveryRepository + prefs *EmailPrefsRepository + settings *Settings + sender mail.Sender + logger *slog.Logger + nudge chan struct{} + now func() time.Time +} + +func newEmailWorker( + pool *pgxpool.Pool, + deliveries *DeliveryRepository, + prefs *EmailPrefsRepository, + settings *Settings, + sender mail.Sender, +) *EmailWorker { + return &EmailWorker{ + pool: pool, + deliveries: deliveries, + prefs: prefs, + settings: settings, + sender: sender, + logger: slog.Default().With("component", "notifications.email"), + nudge: make(chan struct{}, 1), + now: time.Now, + } +} + +// Nudge schedules a near-term pass so per-episode emails follow fanout within +// seconds instead of waiting for the next poll. Non-blocking. +func (w *EmailWorker) Nudge() { + if w == nil { + return + } + select { + case w.nudge <- struct{}{}: + default: + } +} + +// Run sweeps eligible accounts until ctx is canceled. +func (w *EmailWorker) Run(ctx context.Context) { + ticker := time.NewTicker(emailPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-w.nudge: + select { + case <-ctx.Done(): + return + case <-time.After(emailNudgeDelay): + } + } + if !w.settings.EmailEnabled(ctx) || !w.sender.Enabled(ctx) { + continue + } + w.runPass(ctx) + } +} + +// runPass attempts one send per eligible account. Failures back off per +// account; the pass aborts entirely on ErrNotConfigured or after a few +// consecutive failures, since both indicate a global SMTP problem. +func (w *EmailWorker) runPass(ctx context.Context) { + recipients, err := w.prefs.ListActiveRecipients(ctx) + if err != nil { + w.logger.Error("email pass: list recipients failed", "error", err) + return + } + if len(recipients) == 0 { + return + } + allowPerEpisode := w.settings.EmailAllowPerEpisode(ctx) + digestHour := w.settings.EmailDigestHour(ctx) + now := w.now() + + failures := 0 + for _, rec := range recipients { + if ctx.Err() != nil || failures >= emailMaxFailuresPerPass { + return + } + if !emailRetryEligible(now, rec.LastAttemptAt, rec.ConsecutiveFailures) { + continue + } + mode := effectiveEmailMode(rec.Mode, allowPerEpisode) + switch mode { + case EmailModePerEpisode: + // Cheap pre-check so idle accounts don't open a claim + // transaction every pass. A stale watermark only ever + // produces a harmless extra claim. + pending, err := w.prefs.HasDeliveriesSince(ctx, rec.UserID, + Cursor{CreatedAt: rec.WatermarkCreatedAt, ID: rec.WatermarkID}) + if err != nil { + w.logger.Warn("email pass: pending check failed", "user_id", rec.UserID, "error", err) + continue + } + if !pending { + continue + } + case EmailModeDailyDigest: + if !emailDigestDue(now, digestHour, rec.LastDigestAt) { + continue + } + default: + continue + } + if err := w.processAccount(ctx, rec); err != nil { + if errors.Is(err, mail.ErrNotConfigured) { + return // email turned off mid-pass; nothing else will send either + } + failures++ + w.logger.Warn("email send failed", "user_id", rec.UserID, "mode", mode, "error", err) + } + } +} + +// processAccount sends one account's pending notifications under the prefs +// row lock. The SMTP send happens inside the claim transaction: the row lock +// is per-account and only contends with other nodes, and committing the +// watermark only after a successful send is what makes the channel durable. +func (w *EmailWorker) processAccount(ctx context.Context, rec emailRecipient) error { + tx, err := w.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin email dispatch tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + claimed, err := w.prefs.claimForUpdate(ctx, tx, rec.UserID) + if err != nil { + return err + } + if claimed == nil { + return nil // another node is handling this account + } + + // Re-derive eligibility from the locked row: the pre-scan snapshot may + // predate a user mode flip or another node's digest stamp. + mode := effectiveEmailMode(claimed.Mode, w.settings.EmailAllowPerEpisode(ctx)) + switch mode { + case EmailModePerEpisode: + case EmailModeDailyDigest: + if !emailDigestDue(w.now(), w.settings.EmailDigestHour(ctx), claimed.LastDigestAt) { + return nil + } + default: + return nil + } + + since := Cursor{CreatedAt: claimed.WatermarkCreatedAt, ID: claimed.WatermarkID} + rows, err := w.deliveries.ListForUserSince(ctx, tx, rec.UserID, since, emailFetchLimit) + if err != nil { + return err + } + + var digestAt *time.Time + if mode == EmailModeDailyDigest { + now := w.now() + digestAt = &now + } + if len(rows) == 0 { + // Nothing new. Digests still stamp so eligibility stops re-checking + // until tomorrow; the watermark needs no update. + if digestAt != nil { + if err := w.prefs.markSent(ctx, tx, rec.UserID, since, digestAt); err != nil { + return err + } + return tx.Commit(ctx) + } + return nil + } + + items := rows + if mode == EmailModeDailyDigest { + // The digest reports what the user hasn't seen; rows already read in + // another client are skipped but the watermark still passes them. + items = make([]DeliveryRow, 0, len(rows)) + for _, row := range rows { + if row.ReadAt == nil { + items = append(items, row) + } + } + } + + last := rows[len(rows)-1] + watermark := Cursor{CreatedAt: last.CreatedAt, ID: last.ID} + + if len(items) > 0 { + content := composeNotificationEmail(mode, items, w.settings.EmailExternalURL(ctx)) + err = w.sender.Send(ctx, mail.Message{ + To: []string{rec.Email}, + Subject: content.Subject, + TextBody: content.Text, + HTMLBody: content.HTML, + }) + if err != nil { + if errors.Is(err, mail.ErrNotConfigured) { + return err + } + if markErr := w.prefs.markFailure(ctx, tx, rec.UserID); markErr != nil { + return errors.Join(err, markErr) + } + if commitErr := tx.Commit(ctx); commitErr != nil { + return errors.Join(err, commitErr) + } + return err + } + w.logger.Info("notification email sent", + "user_id", rec.UserID, "mode", mode, "items", len(items)) + } + + if err := w.prefs.markSent(ctx, tx, rec.UserID, watermark, digestAt); err != nil { + return err + } + return tx.Commit(ctx) +} + +// Errors surfaced by SetEmailMode for the API layer to map to 4xx responses. +var ( + ErrEmailModeInvalid = errors.New("invalid email notification mode") + ErrEmailModeNotAllowed = errors.New("per-episode email is disabled by the administrator") + ErrEmailNoAddress = errors.New("account has no email address") +) + +// EmailAvailable reports whether the email channel can deliver right now: +// a sender is wired, the kill switch is on, and SMTP is configured. +func (s *System) EmailAvailable(ctx context.Context) bool { + return s != nil && s.emailWorker != nil && + s.Settings.EmailEnabled(ctx) && s.mailSender.Enabled(ctx) +} + +// EmailMode returns the account's chosen email mode (off when never set). +func (s *System) EmailMode(ctx context.Context, userID int) (string, error) { + if s == nil || s.EmailPrefs == nil { + return EmailModeOff, nil + } + prefs, err := s.EmailPrefs.Get(ctx, userID) + if err != nil { + return "", err + } + return prefs.Mode, nil +} + +// SetEmailMode validates and stores the account's email mode. Enabling +// requires an email address on the account and, for per-episode, the admin +// allowance. +func (s *System) SetEmailMode(ctx context.Context, userID int, mode string) error { + if s == nil || s.EmailPrefs == nil { + return ErrEmailModeInvalid + } + if !ValidEmailMode(mode) { + return ErrEmailModeInvalid + } + if mode == EmailModePerEpisode && !s.Settings.EmailAllowPerEpisode(ctx) { + return ErrEmailModeNotAllowed + } + if mode != EmailModeOff { + var email string + err := s.pool.QueryRow(ctx, + `SELECT COALESCE(email, '') FROM users WHERE id = $1`, userID, + ).Scan(&email) + if err != nil { + return fmt.Errorf("look up account email: %w", err) + } + if email == "" { + return ErrEmailNoAddress + } + } + return s.EmailPrefs.SetMode(ctx, userID, mode) +} + +// EmailDispatcher plugs the email worker into the MultiDispatcher: a new +// delivery just nudges the sweep, which reads everything since the watermark. +// No per-delivery state is kept, so dropped nudges cost only poll latency. +type EmailDispatcher struct { + worker *EmailWorker +} + +func newEmailDispatcher(worker *EmailWorker) *EmailDispatcher { + return &EmailDispatcher{worker: worker} +} + +// Dispatch implements Dispatcher. +func (d *EmailDispatcher) Dispatch(_ context.Context, _ DeliveryRow) error { + if d != nil { + d.worker.Nudge() + } + return nil +} diff --git a/internal/notifications/email_logic_test.go b/internal/notifications/email_logic_test.go new file mode 100644 index 00000000..064d693e --- /dev/null +++ b/internal/notifications/email_logic_test.go @@ -0,0 +1,191 @@ +package notifications + +import ( + "fmt" + "strings" + "testing" + "time" +) + +func TestEffectiveEmailMode(t *testing.T) { + if got := effectiveEmailMode(EmailModePerEpisode, true); got != EmailModePerEpisode { + t.Fatalf("allowed per-episode coerced to %q", got) + } + if got := effectiveEmailMode(EmailModePerEpisode, false); got != EmailModeDailyDigest { + t.Fatalf("disallowed per-episode should coerce to digest, got %q", got) + } + if got := effectiveEmailMode(EmailModeDailyDigest, false); got != EmailModeDailyDigest { + t.Fatalf("digest mode changed to %q", got) + } + if got := effectiveEmailMode(EmailModeOff, true); got != EmailModeOff { + t.Fatalf("off mode changed to %q", got) + } +} + +func TestEmailDigestDue(t *testing.T) { + loc := time.UTC + morning := time.Date(2026, 6, 11, 7, 30, 0, 0, loc) + afternoon := time.Date(2026, 6, 11, 14, 0, 0, 0, loc) + yesterday := time.Date(2026, 6, 10, 9, 0, 0, 0, loc) + today := time.Date(2026, 6, 11, 8, 5, 0, 0, loc) + + if emailDigestDue(morning, 8, nil) { + t.Fatal("digest due before today's send hour") + } + if !emailDigestDue(afternoon, 8, nil) { + t.Fatal("first-ever digest not due after send hour") + } + if !emailDigestDue(afternoon, 8, &yesterday) { + t.Fatal("digest not due when last one was yesterday") + } + if emailDigestDue(afternoon, 8, &today) { + t.Fatal("digest due twice in one day") + } +} + +func TestEmailRetryEligible(t *testing.T) { + now := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) + recent := now.Add(-30 * time.Second) + stale := now.Add(-10 * time.Minute) + + if !emailRetryEligible(now, nil, 0) { + t.Fatal("clean account not eligible") + } + if !emailRetryEligible(now, &recent, 0) { + t.Fatal("successful account not eligible") + } + if emailRetryEligible(now, &recent, 1) { + t.Fatal("eligible 30s after first failure (backoff is 1m)") + } + if !emailRetryEligible(now, &stale, 3) { + t.Fatal("not eligible 10m after third failure (backoff is 4m)") + } + // Large failure counts must not overflow the shift; cap applies. + old := now.Add(-7 * time.Hour) + if !emailRetryEligible(now, &old, 60) { + t.Fatal("not eligible past the 6h backoff cap") + } + if emailRetryEligible(now, &recent, 60) { + t.Fatal("eligible 30s after many failures") + } +} + +// emailEpisodeRow builds an episode.available row for one profile. +func emailEpisodeRow(id, profileID, episodeID string, season, episode int) DeliveryRow { + seriesID := "series-123" + return DeliveryRow{ + Delivery: Delivery{ + ID: id, + ProfileID: profileID, + SeriesID: &seriesID, + EpisodeID: &episodeID, + Type: DeliveryTypeEpisodeAvailable, + ReasonFlags: []byte(`{"favorite":true}`), + CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), + }, + SeriesTitle: "Severance", + EpisodeTitle: fmt.Sprintf("Episode %d", episode), + SeasonNumber: &season, + EpisodeNumber: &episode, + } +} + +func TestCollateEmailItemsDedupesAcrossProfiles(t *testing.T) { + rows := []DeliveryRow{ + emailEpisodeRow("01A", "profile-1", "ep-1", 2, 3), + emailEpisodeRow("01B", "profile-2", "ep-1", 2, 3), // same episode, second profile + emailEpisodeRow("01C", "profile-1", "ep-2", 2, 4), + requestFulfilledTestRow(), + {Delivery: Delivery{ID: "01D", Type: DeliveryTypeWebhookAutoDisabled}}, + } + items := collateEmailItems(rows) + if items.episodes != 2 { + t.Fatalf("expected 2 deduped episodes, got %d", items.episodes) + } + if len(items.series) != 1 || items.series[0].title != "Severance" { + t.Fatalf("unexpected series groups: %+v", items.series) + } + if len(items.requests) != 1 || len(items.others) != 1 { + t.Fatalf("unexpected request/other split: %d/%d", len(items.requests), len(items.others)) + } +} + +func TestCollateEmailItemsSortsEpisodesWithinSeries(t *testing.T) { + rows := []DeliveryRow{ + emailEpisodeRow("01A", "profile-1", "ep-2", 2, 4), + emailEpisodeRow("01B", "profile-1", "ep-1", 2, 3), + } + items := collateEmailItems(rows) + first := items.series[0].episodes[0] + if *first.EpisodeNumber != 3 { + t.Fatalf("episodes not sorted by number: got E%d first", *first.EpisodeNumber) + } +} + +func TestEmailSubject(t *testing.T) { + single := collateEmailItems([]DeliveryRow{emailEpisodeRow("01A", "p1", "ep-1", 2, 3)}) + if got := emailSubject(EmailModePerEpisode, single); got != "New episode of Severance: S02E03 — Episode 3" { + t.Fatalf("unexpected single-episode subject %q", got) + } + + request := collateEmailItems([]DeliveryRow{requestFulfilledTestRow()}) + if got := emailSubject(EmailModePerEpisode, request); got != "Dune is now available" { + t.Fatalf("unexpected single-request subject %q", got) + } + + mixed := collateEmailItems([]DeliveryRow{ + emailEpisodeRow("01A", "p1", "ep-1", 2, 3), + emailEpisodeRow("01B", "p1", "ep-2", 2, 4), + requestFulfilledTestRow(), + }) + if got := emailSubject(EmailModePerEpisode, mixed); got != "Silo: 2 new episodes, 1 request ready" { + t.Fatalf("unexpected mixed subject %q", got) + } + if got := emailSubject(EmailModeDailyDigest, mixed); got != "Silo daily digest: 2 new episodes, 1 request ready" { + t.Fatalf("unexpected digest subject %q", got) + } +} + +func TestComposeNotificationEmailLinks(t *testing.T) { + rows := []DeliveryRow{emailEpisodeRow("01A", "p1", "ep-1", 2, 3)} + + withLinks := composeNotificationEmail(EmailModePerEpisode, rows, "https://silo.example.com") + if !strings.Contains(withLinks.HTML, `href="https://silo.example.com/item/ep-1"`) { + t.Fatalf("episode link missing from HTML:\n%s", withLinks.HTML) + } + if !strings.Contains(withLinks.HTML, `href="https://silo.example.com/settings/notifications"`) { + t.Fatalf("settings link missing from HTML footer:\n%s", withLinks.HTML) + } + + withoutLinks := composeNotificationEmail(EmailModePerEpisode, rows, "") + if strings.Contains(withoutLinks.HTML, "href=") { + t.Fatalf("HTML contains links with no external URL configured:\n%s", withoutLinks.HTML) + } + if !strings.Contains(withoutLinks.Text, "S02E03 — Episode 3") { + t.Fatalf("text body missing episode line:\n%s", withoutLinks.Text) + } +} + +func TestComposeNotificationEmailEscapesHTML(t *testing.T) { + row := emailEpisodeRow("01A", "p1", "ep-1", 2, 3) + row.SeriesTitle = `` + content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, "") + if strings.Contains(content.HTML, "` - content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, "") + content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, emailComposeOptions{}) if strings.Contains(content.HTML, "`, + Title: `Title & bold`, + BodyHTML: `

    trusted

    `, + FooterHTML: `fine print`, + }) + if strings.Contains(out, "