feat(notifications): notification system v1 (#136)
Merge feat/notifications-v1: a durable, profile-scoped notification system spanning availability detection, fanout, multi-channel delivery, and the web/admin UI. 19 commits, ~22k lines, 11 migrations. Core pipeline - Availability seeding with per-library seed markers so back-catalog imports never flood; release_events -> profile interest fanout with settling delay and per-series burst caps; FOR UPDATE SKIP LOCKED claims for multi-node safety; durable per-profile inbox with cross-library dedupe; interest tracked via a userstore decorator so every mutation path feeds one chokepoint. - Request-fulfilled notifications delivered across all channels. - Feature flags default ON as kill switches; seeding and backfill run automatically at startup. Channels - In-app inbox with realtime websocket delivery authenticated by single-use handshake tickets, unread badge, per-reason preferences. - Web Push: self-provisioned VAPID keys (private half encrypted at rest), RFC 8291 end-to-end encrypted payloads, service worker and subscribe flow. - Outbound webhooks (gated behind admin opt-in): generic JSON with HMAC signing and Discord webhooks with rich embeds (posters, provider links, ratings); HTTPS-only private-destination guard at registration and connect time; URLs/secrets encrypted at rest; durable outbox with backoff and consecutive-failure auto-disable. - Email: shared SMTP core (internal/mail) with branded HTML templates; per-profile verified addresses with per-episode or digest delivery and unsubscribe links. - Discord bot DMs with account linking flow. - Admin server channels broadcasting new content and request activity to Discord. Web/admin UI - Notifications inbox page, settings with delivery health, sidebar badge; admin settings redesigned around pipeline and channel cards with email config and synchronous test sends. Specs in docs/superpowers/plans/notifications/ (APNs/FCM deferred to v2; remaining follow-ups tracked in the v1.5 roadmap).
This commit is contained in:
@@ -59,6 +59,9 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# docs/architecture/secret-encryption.md.
|
||||
# SECRET_KEY=replace-with-output-of-openssl-rand-base64-48
|
||||
|
||||
# Optional public URL for Silo. If not set, the server will use the IP address of the container.
|
||||
# SILO_PUBLIC_URL=https://silo.example.com
|
||||
|
||||
# Run from source / advanced overrides
|
||||
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
|
||||
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
|
||||
|
||||
+66
-2
@@ -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"
|
||||
@@ -595,6 +596,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 +616,7 @@ func main() {
|
||||
DB: pool,
|
||||
SecretCipher: dataCipher,
|
||||
EventBus: eventBus,
|
||||
RedisClient: apiRedisClient,
|
||||
LogStreamHub: logStreamHub,
|
||||
RealtimeHub: realtimeHub,
|
||||
EventsHub: eventsHub,
|
||||
@@ -1208,8 +1220,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 {
|
||||
@@ -1271,6 +1285,48 @@ 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,
|
||||
mail.NewSMTPSender(settingsRepo),
|
||||
)
|
||||
userStoreProvider = notifications.WrapUserStoreProvider(userStoreProvider, notificationSystem)
|
||||
deps.Notifications = notificationSystem
|
||||
if libraryIngestExecutor != nil {
|
||||
libraryIngestExecutor.SetAvailabilityDetector(notificationSystem.Detector)
|
||||
}
|
||||
if needsWorkers {
|
||||
notificationSystem.Start(appCtx)
|
||||
defer notificationSystem.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -1575,6 +1631,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.NewSeedContentAvailabilityTask(notificationSystem))
|
||||
taskMgr.Register(tasks.NewRebuildReleaseInterestTask(notificationSystem))
|
||||
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationSystem))
|
||||
}
|
||||
if matchWorker != nil {
|
||||
taskMgr.Register(tasks.NewMatchMediaTask(matchWorker))
|
||||
}
|
||||
@@ -1614,6 +1675,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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// AdminServerChannelsHandler exposes admin CRUD for server notification
|
||||
// channels (community broadcast destinations). All routes are mounted inside
|
||||
// the admin-only group.
|
||||
type AdminServerChannelsHandler struct {
|
||||
system *notifications.System
|
||||
}
|
||||
|
||||
// NewAdminServerChannelsHandler creates the handler.
|
||||
func NewAdminServerChannelsHandler(system *notifications.System) *AdminServerChannelsHandler {
|
||||
return &AdminServerChannelsHandler{system: system}
|
||||
}
|
||||
|
||||
func (h *AdminServerChannelsHandler) service() *notifications.ServerChannelService {
|
||||
if h == nil || h.system == nil {
|
||||
return nil
|
||||
}
|
||||
return h.system.ServerChannels
|
||||
}
|
||||
|
||||
// serverChannelResponse is the API view of a server channel. Like webhooks it
|
||||
// never includes the destination URL (Discord webhook tokens are bearer
|
||||
// credentials in the URL path) or the stored signing secret — only url_host.
|
||||
type serverChannelResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
URLHost string `json:"url_host"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NotifyNewMovies bool `json:"notify_new_movies"`
|
||||
NotifyNewEpisodes bool `json:"notify_new_episodes"`
|
||||
NotifyRequestSubmitted bool `json:"notify_request_submitted"`
|
||||
NotifyRequestApproved bool `json:"notify_request_approved"`
|
||||
NotifyRequestDeclined bool `json:"notify_request_declined"`
|
||||
NotifyRequestFulfilled bool `json:"notify_request_fulfilled"`
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// SigningSecret is present only in create / rotate-secret responses.
|
||||
SigningSecret string `json:"signing_secret,omitempty"`
|
||||
}
|
||||
|
||||
func serverChannelToResponse(ch notifications.ServerChannel) serverChannelResponse {
|
||||
return serverChannelResponse{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
Type: ch.Type,
|
||||
URLHost: ch.URLHost,
|
||||
Enabled: ch.Enabled,
|
||||
NotifyNewMovies: ch.NotifyNewMovies,
|
||||
NotifyNewEpisodes: ch.NotifyNewEpisodes,
|
||||
NotifyRequestSubmitted: ch.NotifyRequestSubmitted,
|
||||
NotifyRequestApproved: ch.NotifyRequestApproved,
|
||||
NotifyRequestDeclined: ch.NotifyRequestDeclined,
|
||||
NotifyRequestFulfilled: ch.NotifyRequestFulfilled,
|
||||
ConsecutiveFailures: ch.ConsecutiveFailures,
|
||||
DisabledReason: ch.DisabledReason,
|
||||
LastSuccessAt: ch.LastSuccessAt,
|
||||
LastFailureAt: ch.LastFailureAt,
|
||||
LastFailureStatus: ch.LastFailureStatus,
|
||||
LastFailureMessage: ch.LastFailureMessage,
|
||||
CreatedAt: ch.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type serverChannelRequest struct {
|
||||
Name *string `json:"name"`
|
||||
URL *string `json:"url"`
|
||||
Type *string `json:"type"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
NotifyNewMovies *bool `json:"notify_new_movies"`
|
||||
NotifyNewEpisodes *bool `json:"notify_new_episodes"`
|
||||
NotifyRequestSubmitted *bool `json:"notify_request_submitted"`
|
||||
NotifyRequestApproved *bool `json:"notify_request_approved"`
|
||||
NotifyRequestDeclined *bool `json:"notify_request_declined"`
|
||||
NotifyRequestFulfilled *bool `json:"notify_request_fulfilled"`
|
||||
}
|
||||
|
||||
func (r serverChannelRequest) toInput() notifications.ServerChannelInput {
|
||||
return notifications.ServerChannelInput{
|
||||
Name: r.Name,
|
||||
URL: r.URL,
|
||||
Type: r.Type,
|
||||
Enabled: r.Enabled,
|
||||
NotifyNewMovies: r.NotifyNewMovies,
|
||||
NotifyNewEpisodes: r.NotifyNewEpisodes,
|
||||
NotifyRequestSubmitted: r.NotifyRequestSubmitted,
|
||||
NotifyRequestApproved: r.NotifyRequestApproved,
|
||||
NotifyRequestDeclined: r.NotifyRequestDeclined,
|
||||
NotifyRequestFulfilled: r.NotifyRequestFulfilled,
|
||||
}
|
||||
}
|
||||
|
||||
func writeServerChannelError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, notifications.ErrServerChannelsDisabled):
|
||||
writeError(w, http.StatusForbidden, "server_channels_disabled", "Server channels are disabled")
|
||||
case errors.Is(err, notifications.ErrServerChannelNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Server channel not found")
|
||||
case errors.Is(err, notifications.ErrServerChannelLimit):
|
||||
writeError(w, http.StatusUnprocessableEntity, "limit_reached", "Server channel limit reached")
|
||||
case errors.Is(err, notifications.ErrServerChannelInvalid):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Server channel operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleList handles GET /admin/notifications/server-channels.
|
||||
func (h *AdminServerChannelsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
service := h.service()
|
||||
if service == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
||||
return
|
||||
}
|
||||
channels, err := service.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list server channels")
|
||||
return
|
||||
}
|
||||
responses := make([]serverChannelResponse, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
responses = append(responses, serverChannelToResponse(ch))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"channels": responses})
|
||||
}
|
||||
|
||||
// HandleCreate handles POST /admin/notifications/server-channels. For generic
|
||||
// channels the response carries the signing secret exactly once.
|
||||
func (h *AdminServerChannelsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
service := h.service()
|
||||
if service == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
||||
return
|
||||
}
|
||||
var req serverChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
ch, signingSecret, err := service.Create(r.Context(), apimw.GetUserID(r.Context()), req.toInput())
|
||||
if err != nil {
|
||||
writeServerChannelError(w, err)
|
||||
return
|
||||
}
|
||||
response := serverChannelToResponse(*ch)
|
||||
response.SigningSecret = signingSecret
|
||||
writeJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// HandleUpdate handles PUT /admin/notifications/server-channels/{id}.
|
||||
func (h *AdminServerChannelsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
service := h.service()
|
||||
if service == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
||||
return
|
||||
}
|
||||
var req serverChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
ch, err := service.Update(r.Context(), chi.URLParam(r, "id"), req.toInput())
|
||||
if err != nil {
|
||||
writeServerChannelError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, serverChannelToResponse(*ch))
|
||||
}
|
||||
|
||||
// HandleDelete handles DELETE /admin/notifications/server-channels/{id}.
|
||||
// Idempotent.
|
||||
func (h *AdminServerChannelsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
service := h.service()
|
||||
if service == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
||||
return
|
||||
}
|
||||
if err := service.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
|
||||
writeServerChannelError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleRotateSecret handles POST /admin/notifications/server-channels/{id}/rotate-secret.
|
||||
func (h *AdminServerChannelsHandler) HandleRotateSecret(w http.ResponseWriter, r *http.Request) {
|
||||
service := h.service()
|
||||
if service == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
||||
return
|
||||
}
|
||||
signingSecret, err := service.RotateSecret(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeServerChannelError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"signing_secret": signingSecret})
|
||||
}
|
||||
|
||||
// HandleTest handles POST /admin/notifications/server-channels/{id}/test. The
|
||||
// test send is synchronous and never touches the watermark or failure
|
||||
// counters.
|
||||
func (h *AdminServerChannelsHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
|
||||
service := h.service()
|
||||
if service == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
||||
return
|
||||
}
|
||||
result, err := service.Test(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeServerChannelError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
func TestWriteServerChannelErrorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{notifications.ErrServerChannelsDisabled, http.StatusForbidden},
|
||||
{notifications.ErrServerChannelNotFound, http.StatusNotFound},
|
||||
{notifications.ErrServerChannelLimit, http.StatusUnprocessableEntity},
|
||||
{fmt.Errorf("%w: name is required", notifications.ErrServerChannelInvalid), http.StatusBadRequest},
|
||||
{fmt.Errorf("boom"), http.StatusInternalServerError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
writeServerChannelError(rec, tc.err)
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("writeServerChannelError(%v) = %d, want %d", tc.err, rec.Code, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The response shape must never leak the destination URL (a bearer credential
|
||||
// for Discord webhooks) or the stored signing secret; signing_secret appears
|
||||
// only when explicitly set by the create/rotate paths.
|
||||
func TestServerChannelResponseNeverLeaksSecrets(t *testing.T) {
|
||||
secret := "ciphertext-secret"
|
||||
ch := notifications.ServerChannel{
|
||||
ID: "ch-1",
|
||||
Name: "Community",
|
||||
Type: "discord",
|
||||
URLCiphertext: "enc:v1:secret-url-material",
|
||||
URLHost: "discord.com",
|
||||
SigningSecretCiphertext: &secret,
|
||||
Enabled: true,
|
||||
}
|
||||
body, err := json.Marshal(serverChannelToResponse(ch))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded := string(body)
|
||||
if strings.Contains(encoded, "secret-url-material") || strings.Contains(encoded, "ciphertext-secret") {
|
||||
t.Fatalf("response leaked ciphertext material: %s", encoded)
|
||||
}
|
||||
if strings.Contains(encoded, "signing_secret") {
|
||||
t.Fatalf("signing_secret must be omitted unless explicitly set: %s", encoded)
|
||||
}
|
||||
if !strings.Contains(encoded, `"url_host":"discord.com"`) {
|
||||
t.Fatalf("url_host missing from response: %s", encoded)
|
||||
}
|
||||
|
||||
response := serverChannelToResponse(ch)
|
||||
response.SigningSecret = "shown-once"
|
||||
body, err = json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"signing_secret":"shown-once"`) {
|
||||
t.Fatalf("explicit signing_secret missing: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A handler with no notifications system reports 503 instead of panicking.
|
||||
func TestServerChannelHandlersUnavailableWithoutSystem(t *testing.T) {
|
||||
h := NewAdminServerChannelsHandler(nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleList(rec, httptest.NewRequest(http.MethodGet, "/admin/notifications/server-channels", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("HandleList without system = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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.",
|
||||
HTMLBody: silomail.RenderLayout(silomail.LayoutOptions{
|
||||
Preheader: "Outbound email from your Silo server is configured correctly.",
|
||||
Title: "Outbound email is working",
|
||||
BodyHTML: silomail.EmailParagraph("This is a test email from your Silo server.") +
|
||||
silomail.EmailParagraph("If you're reading it, the SMTP settings are correct and "+
|
||||
"notification emails will look like this one."),
|
||||
}),
|
||||
})
|
||||
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)
|
||||
}
|
||||
@@ -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,14 +479,19 @@ 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 {
|
||||
// Drop the frame but keep the stream open (same contract as
|
||||
// writeSnapshotFrame): durable state covers the gap on the next
|
||||
// event or reconnect, while closing the socket tears down every
|
||||
// channel the client subscribed to.
|
||||
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
data = snapshot
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
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"`
|
||||
Email capabilityAccountChannel `json:"email"`
|
||||
Discord capabilityAccountChannel `json:"discord"`
|
||||
}
|
||||
|
||||
// capabilityAccountChannel describes an account-level digest channel (email,
|
||||
// Discord DMs).
|
||||
type capabilityAccountChannel 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 {
|
||||
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}
|
||||
}
|
||||
}
|
||||
email := capabilityAccountChannel{Modes: []string{}}
|
||||
if h.system.EmailAvailable(r.Context()) {
|
||||
modes := []string{notifications.ChannelModeDailyDigest}
|
||||
if h.system.Settings.EmailAllowPerEpisode(r.Context()) {
|
||||
modes = append(modes,
|
||||
notifications.ChannelModePerEpisode,
|
||||
notifications.ChannelModePerEpisodeAndDigest)
|
||||
}
|
||||
email = capabilityAccountChannel{
|
||||
Available: true,
|
||||
Modes: modes,
|
||||
DigestHour: h.system.Settings.EmailDigestHour(r.Context()),
|
||||
}
|
||||
}
|
||||
discordCap := capabilityAccountChannel{Modes: []string{}}
|
||||
if h.system.DiscordAvailable(r.Context()) {
|
||||
modes := []string{notifications.ChannelModeDailyDigest}
|
||||
if h.system.Settings.DiscordAllowPerEpisode(r.Context()) {
|
||||
modes = append(modes,
|
||||
notifications.ChannelModePerEpisode,
|
||||
notifications.ChannelModePerEpisodeAndDigest)
|
||||
}
|
||||
discordCap = capabilityAccountChannel{
|
||||
Available: true,
|
||||
Modes: modes,
|
||||
DigestHour: h.system.Settings.DiscordDigestHour(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,
|
||||
Discord: discordCap,
|
||||
})
|
||||
}
|
||||
|
||||
// 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()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/discord"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
// discordSettingsPath is the SPA page the OAuth callback redirects back to,
|
||||
// with ?discord_linked=1 or ?discord_error=<reason> for the page to toast.
|
||||
const discordSettingsPath = "/settings/notifications"
|
||||
|
||||
// DiscordNotificationsHandler serves the account-level Discord DM channel:
|
||||
// preferences, the OAuth account-link flow, and the admin bot test. The
|
||||
// preferences and link-init endpoints require auth; the link callback is
|
||||
// public because Discord redirects the browser there without credentials —
|
||||
// the one-time server-side state row authenticates it instead.
|
||||
type DiscordNotificationsHandler struct {
|
||||
system *notifications.System
|
||||
publicURL string
|
||||
}
|
||||
|
||||
// NewDiscordNotificationsHandler creates a DiscordNotificationsHandler.
|
||||
// publicURL may be empty, in which case linking is reported unavailable
|
||||
// (Discord needs a stable redirect_uri origin).
|
||||
func NewDiscordNotificationsHandler(system *notifications.System, publicURL string) *DiscordNotificationsHandler {
|
||||
return &DiscordNotificationsHandler{system: system, publicURL: strings.TrimRight(publicURL, "/")}
|
||||
}
|
||||
|
||||
func (h *DiscordNotificationsHandler) redirectURI() string {
|
||||
return h.publicURL + "/api/v1/notifications/discord/link/callback"
|
||||
}
|
||||
|
||||
// discordPreferencesResponse is the account-level Discord DM setting plus
|
||||
// link state and health.
|
||||
type discordPreferencesResponse struct {
|
||||
Linked bool `json:"linked"`
|
||||
DiscordUsername string `json:"discord_username,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
LinkFailure string `json:"link_failure,omitempty"`
|
||||
}
|
||||
|
||||
type updateDiscordPreferencesRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
func discordPrefsResponse(prefs notifications.DiscordPrefs) discordPreferencesResponse {
|
||||
return discordPreferencesResponse{
|
||||
Linked: prefs.Linked(),
|
||||
DiscordUsername: prefs.DiscordUsername,
|
||||
Mode: prefs.Mode,
|
||||
LinkFailure: prefs.LinkFailure,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetPreferences handles GET /notifications/discord-preferences.
|
||||
func (h *DiscordNotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
prefs, err := h.system.DiscordPrefsFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load Discord preferences")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, discordPrefsResponse(prefs))
|
||||
}
|
||||
|
||||
// HandleUpdatePreferences handles PUT /notifications/discord-preferences.
|
||||
func (h *DiscordNotificationsHandler) HandleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
var req updateDiscordPreferencesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.system.SetDiscordMode(r.Context(), userID, req.Mode)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, notifications.ErrDiscordModeInvalid):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Unknown Discord notification mode")
|
||||
return
|
||||
case errors.Is(err, notifications.ErrDiscordModeNotAllowed):
|
||||
writeError(w, http.StatusBadRequest, "not_allowed", "Per-episode Discord DMs are disabled by the administrator")
|
||||
return
|
||||
case errors.Is(err, notifications.ErrDiscordNotLinked):
|
||||
writeError(w, http.StatusBadRequest, "not_linked", "Link a Discord account first")
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save Discord preferences")
|
||||
return
|
||||
}
|
||||
prefs, err := h.system.DiscordPrefsFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load Discord preferences")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, discordPrefsResponse(prefs))
|
||||
}
|
||||
|
||||
// HandleUnlink handles DELETE /notifications/discord-link: removes the
|
||||
// linked identity and switches the channel off.
|
||||
func (h *DiscordNotificationsHandler) HandleUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if err := h.system.UnlinkDiscord(r.Context(), userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to unlink Discord account")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, discordPreferencesResponse{Mode: notifications.ChannelModeOff})
|
||||
}
|
||||
|
||||
type discordLinkInitResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// HandleLinkInit handles POST /notifications/discord/link/init: records a
|
||||
// one-time state for the signed-in account and returns the Discord consent
|
||||
// URL for the SPA to navigate to.
|
||||
func (h *DiscordNotificationsHandler) HandleLinkInit(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if !h.system.DiscordAvailable(r.Context()) {
|
||||
writeError(w, http.StatusConflict, "not_configured", "Discord integration is not enabled by the administrator")
|
||||
return
|
||||
}
|
||||
if h.publicURL == "" {
|
||||
writeError(w, http.StatusConflict, "no_public_url", "Linking requires SILO_PUBLIC_URL to be configured")
|
||||
return
|
||||
}
|
||||
|
||||
stateBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(stateBytes); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start Discord link")
|
||||
return
|
||||
}
|
||||
state := hex.EncodeToString(stateBytes)
|
||||
if err := h.system.BeginDiscordLink(r.Context(), state, userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start Discord link")
|
||||
return
|
||||
}
|
||||
|
||||
query := url.Values{
|
||||
"client_id": {h.system.Settings.DiscordClientID(r.Context())},
|
||||
"response_type": {"code"},
|
||||
"scope": {"identify"},
|
||||
"redirect_uri": {h.redirectURI()},
|
||||
"state": {state},
|
||||
}
|
||||
writeJSON(w, http.StatusOK, discordLinkInitResponse{
|
||||
URL: discord.AuthorizeURL + "?" + query.Encode(),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleLinkCallback handles GET /notifications/discord/link/callback.
|
||||
// Public route: Discord redirects the user's browser here. The one-time
|
||||
// state row authenticates the request and recovers which account started
|
||||
// the flow; the browser is then sent back to the settings page.
|
||||
func (h *DiscordNotificationsHandler) HandleLinkCallback(w http.ResponseWriter, r *http.Request) {
|
||||
redirectBack := func(params url.Values) {
|
||||
http.Redirect(w, r, discordSettingsPath+"?"+params.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// The admin may have switched the integration off between init and
|
||||
// callback; don't complete a link for a disabled channel.
|
||||
if !h.system.DiscordAvailable(r.Context()) {
|
||||
redirectBack(url.Values{"discord_error": {"disabled"}})
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
if query.Get("error") != "" {
|
||||
// The user declined on Discord's consent screen.
|
||||
redirectBack(url.Values{"discord_error": {"denied"}})
|
||||
return
|
||||
}
|
||||
state := query.Get("state")
|
||||
code := query.Get("code")
|
||||
if state == "" || code == "" {
|
||||
redirectBack(url.Values{"discord_error": {"invalid_callback"}})
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok, err := h.system.ConsumeDiscordLinkState(r.Context(), state)
|
||||
if err != nil || !ok {
|
||||
redirectBack(url.Values{"discord_error": {"state_invalid"}})
|
||||
return
|
||||
}
|
||||
if _, err := h.system.CompleteDiscordLink(r.Context(), userID, code, h.redirectURI()); err != nil {
|
||||
redirectBack(url.Values{"discord_error": {"exchange_failed"}})
|
||||
return
|
||||
}
|
||||
redirectBack(url.Values{"discord_linked": {"1"}})
|
||||
}
|
||||
|
||||
type discordTestResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// HandleAdminTest handles POST /admin/notifications/discord/test: verifies
|
||||
// the configured bot token by fetching the bot's own identity.
|
||||
func (h *DiscordNotificationsHandler) HandleAdminTest(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
botUser, err := h.system.TestDiscordBot(r.Context())
|
||||
duration := time.Since(start).Milliseconds()
|
||||
if errors.Is(err, notifications.ErrDiscordNotConfigured) {
|
||||
writeJSON(w, http.StatusOK, discordTestResponse{
|
||||
OK: false, DurationMS: duration, Message: "Bot token is not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, discordTestResponse{
|
||||
OK: false, DurationMS: duration, Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, discordTestResponse{
|
||||
OK: true, DurationMS: duration, Message: "Connected as " + botUser.Username,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
// emailPreferencesResponse is one profile's email notification state. The
|
||||
// channel is profile-scoped: each profile verifies its own destination
|
||||
// address and receives nothing until it has one — there is no account-email
|
||||
// fallback.
|
||||
type emailPreferencesResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
// CustomEmail is the verified destination ('' = none; channel inert).
|
||||
CustomEmail string `json:"custom_email"`
|
||||
// PendingEmail is an address awaiting link-click verification.
|
||||
PendingEmail string `json:"pending_email"`
|
||||
// CanEditAddress is false for child profiles, which cannot set
|
||||
// addresses (and so cannot receive email notifications).
|
||||
CanEditAddress bool `json:"can_edit_address"`
|
||||
}
|
||||
|
||||
type updateEmailPreferencesRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type updateEmailAddressRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func emailPreferencesPayload(state notifications.EmailPreferencesState) emailPreferencesResponse {
|
||||
return emailPreferencesResponse{
|
||||
Mode: state.Mode,
|
||||
CustomEmail: state.CustomEmail,
|
||||
PendingEmail: state.PendingEmail,
|
||||
CanEditAddress: !state.IsChild,
|
||||
}
|
||||
}
|
||||
|
||||
// respondEmailPreferences re-reads and writes the profile's full email state,
|
||||
// so every mutation returns the same shape as GET.
|
||||
func (h *NotificationsHandler) respondEmailPreferences(w http.ResponseWriter, r *http.Request, userID int, profileID string) {
|
||||
state, err := h.system.EmailPreferences(r.Context(), userID, profileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load email preferences")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, emailPreferencesPayload(state))
|
||||
}
|
||||
|
||||
// HandleGetEmailPreferences handles GET /notifications/email-preferences.
|
||||
func (h *NotificationsHandler) HandleGetEmailPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
h.respondEmailPreferences(w, r, apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()))
|
||||
}
|
||||
|
||||
// HandleUpdateEmailPreferences handles PUT /notifications/email-preferences.
|
||||
func (h *NotificationsHandler) HandleUpdateEmailPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(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, profileID, 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", "Verify an email address for this profile first")
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save email preferences")
|
||||
return
|
||||
}
|
||||
h.respondEmailPreferences(w, r, userID, profileID)
|
||||
}
|
||||
|
||||
// HandleRequestEmailAddress handles PUT /notifications/email-preferences/address.
|
||||
// It stores the candidate address and emails it a verification link; the
|
||||
// address only becomes the destination once that link is clicked.
|
||||
func (h *NotificationsHandler) HandleRequestEmailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
var req updateEmailAddressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.system.RequestEmailAddress(r.Context(), userID, profileID, req.Email)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, notifications.ErrEmailInvalidAddress):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid email address")
|
||||
return
|
||||
case errors.Is(err, notifications.ErrEmailChildProfile):
|
||||
writeError(w, http.StatusForbidden, "child_profile", "Child profiles cannot set a custom notification address")
|
||||
return
|
||||
case errors.Is(err, notifications.ErrEmailAddressInUse):
|
||||
writeError(w, http.StatusConflict, "address_in_use", "That email address is already used by another profile or account")
|
||||
return
|
||||
case errors.Is(err, notifications.ErrEmailVerifyRateLimited):
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many verification emails; try again later")
|
||||
return
|
||||
case errors.Is(err, notifications.ErrEmailNoLinkBase):
|
||||
writeError(w, http.StatusConflict, "no_external_url", "The server has no external URL configured for verification links")
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send the verification email")
|
||||
return
|
||||
}
|
||||
h.respondEmailPreferences(w, r, userID, profileID)
|
||||
}
|
||||
|
||||
// HandleClearEmailAddress handles DELETE /notifications/email-preferences/address.
|
||||
func (h *NotificationsHandler) HandleClearEmailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
err := h.system.ClearEmailAddress(r.Context(), userID, profileID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, notifications.ErrEmailChildProfile):
|
||||
writeError(w, http.StatusForbidden, "child_profile", "Child profiles cannot change the notification address")
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove the custom address")
|
||||
return
|
||||
}
|
||||
h.respondEmailPreferences(w, r, userID, profileID)
|
||||
}
|
||||
|
||||
// EmailLinkHandler serves the public tokenized email endpoints: address
|
||||
// verification and unsubscribe. Both are clicked from email clients on
|
||||
// devices that may have no Silo session, so they render minimal standalone
|
||||
// HTML instead of redirecting into the authenticated app.
|
||||
type EmailLinkHandler struct {
|
||||
system *notifications.System
|
||||
}
|
||||
|
||||
// NewEmailLinkHandler creates an EmailLinkHandler.
|
||||
func NewEmailLinkHandler(system *notifications.System) *EmailLinkHandler {
|
||||
return &EmailLinkHandler{system: system}
|
||||
}
|
||||
|
||||
// HandleVerify handles GET /notifications/email/verify?token=...
|
||||
func (h *EmailLinkHandler) HandleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
outcome, err := h.system.VerifyEmailToken(r.Context(), r.URL.Query().Get("token"))
|
||||
switch {
|
||||
case err != nil:
|
||||
writeEmailLinkPage(w, http.StatusInternalServerError, "Something went wrong",
|
||||
"The address could not be verified. Try the link again in a moment.")
|
||||
case outcome == notifications.EmailVerifyConflict:
|
||||
writeEmailLinkPage(w, http.StatusConflict, "Address already in use",
|
||||
"This address now belongs to another profile or account. Choose a different address in Silo's notification settings.")
|
||||
case outcome == notifications.EmailVerifyInvalid:
|
||||
writeEmailLinkPage(w, http.StatusBadRequest, "Link expired or already used",
|
||||
"Request a new verification email from Silo's notification settings.")
|
||||
default:
|
||||
writeEmailLinkPage(w, http.StatusOK, "Address verified",
|
||||
"Silo notifications for this profile will now be delivered here. You can close this page.")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUnsubscribe handles GET and POST /notifications/email/unsubscribe?token=...
|
||||
// POST is the RFC 8058 one-click target mail clients call directly.
|
||||
func (h *EmailLinkHandler) HandleUnsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := h.system.UnsubscribeEmail(r.Context(), r.URL.Query().Get("token"))
|
||||
switch {
|
||||
case err != nil:
|
||||
writeEmailLinkPage(w, http.StatusInternalServerError, "Something went wrong",
|
||||
"Could not unsubscribe. Try the link again in a moment.")
|
||||
case !ok:
|
||||
writeEmailLinkPage(w, http.StatusBadRequest, "Link invalid",
|
||||
"This unsubscribe link is no longer valid. Manage notifications in Silo's settings.")
|
||||
default:
|
||||
writeEmailLinkPage(w, http.StatusOK, "Unsubscribed",
|
||||
"This profile will no longer receive notification emails. Re-enable them any time in Silo's settings.")
|
||||
}
|
||||
}
|
||||
|
||||
// writeEmailLinkPage renders the minimal standalone page behind tokenized
|
||||
// email links.
|
||||
func writeEmailLinkPage(w http.ResponseWriter, status int, title, detail string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s — Silo</title></head>
|
||||
<body style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#101014;color:#e8e8ec;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;">
|
||||
<div style="max-width:420px;padding:32px;text-align:center;">
|
||||
<h1 style="font-size:20px;margin:0 0 12px;">%s</h1>
|
||||
<p style="font-size:14px;color:#9a9aa4;margin:0;">%s</p>
|
||||
</div></body></html>`,
|
||||
html.EscapeString(title), html.EscapeString(title), html.EscapeString(detail))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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"`
|
||||
NotifyRequests bool `json:"notify_requests"`
|
||||
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,
|
||||
NotifyRequests: hook.NotifyRequests,
|
||||
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"`
|
||||
NotifyRequests *bool `json:"notify_requests"`
|
||||
}
|
||||
|
||||
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,
|
||||
NotifyRequests: r.NotifyRequests,
|
||||
}
|
||||
}
|
||||
|
||||
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.ErrWebhooksDisabled):
|
||||
writeError(w, http.StatusForbidden, "webhooks_disabled", "Webhooks are disabled by the server administrator")
|
||||
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 {
|
||||
writeWebhookError(w, err)
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -529,6 +531,12 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
if viewerResolver != nil {
|
||||
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
|
||||
}
|
||||
// Server-channel broadcast of request lifecycle events (submitted /
|
||||
// approved / declined). Fulfilled rides the reconcile service's
|
||||
// fulfillment notifier instead.
|
||||
if lifecycle := notifications.NewServerChannelLifecycleNotifier(deps.Notifications); lifecycle != nil {
|
||||
requestSvc.SetLifecycleNotifier(lifecycle)
|
||||
}
|
||||
requestHandler = handlers.NewRequestsHandler(requestSvc)
|
||||
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
|
||||
@@ -1477,6 +1485,28 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
// Discord account-link OAuth callback: public — Discord redirects the
|
||||
// browser here without credentials; the one-time link-state row
|
||||
// authenticates the request and maps it back to the initiating
|
||||
// account. The static path coexists with the authenticated
|
||||
// /notifications subrouter below (static routes win in chi).
|
||||
var discordNotificationsHandler *handlers.DiscordNotificationsHandler
|
||||
if deps.Notifications != nil {
|
||||
discordNotificationsHandler = handlers.NewDiscordNotificationsHandler(deps.Notifications, deps.PublicURL)
|
||||
r.Get("/notifications/discord/link/callback", discordNotificationsHandler.HandleLinkCallback)
|
||||
|
||||
// Tokenized email links: public — clicked from mail clients on
|
||||
// devices without a Silo session; the single-use token (verify)
|
||||
// or per-profile capability token (unsubscribe) authenticates the
|
||||
// request. Static paths coexist with the authenticated
|
||||
// /notifications subrouter below, same as the Discord callback.
|
||||
deps.Notifications.SetPublicURL(deps.PublicURL)
|
||||
emailLinkHandler := handlers.NewEmailLinkHandler(deps.Notifications)
|
||||
r.Get("/notifications/email/verify", emailLinkHandler.HandleVerify)
|
||||
r.Get("/notifications/email/unsubscribe", emailLinkHandler.HandleUnsubscribe)
|
||||
r.Post("/notifications/email/unsubscribe", emailLinkHandler.HandleUnsubscribe)
|
||||
}
|
||||
|
||||
// API key management routes (auth only, no viewer access needed).
|
||||
if apiKeyRepo != nil && authMiddleware != nil {
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -1522,9 +1552,60 @@ 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)
|
||||
// Discord DM channel: the linked identity and mode hang off
|
||||
// the login account, not a profile, so these stay outside
|
||||
// the RequireProfile subrouter below (static paths coexist
|
||||
// with it, same as the public email-link routes above).
|
||||
if discordNotificationsHandler != nil {
|
||||
r.Get("/notifications/discord-preferences", discordNotificationsHandler.HandleGetPreferences)
|
||||
r.Put("/notifications/discord-preferences", discordNotificationsHandler.HandleUpdatePreferences)
|
||||
r.Delete("/notifications/discord-link", discordNotificationsHandler.HandleUnlink)
|
||||
r.Post("/notifications/discord/link/init", discordNotificationsHandler.HandleLinkInit)
|
||||
}
|
||||
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.Get("/email-preferences", notificationsHandler.HandleGetEmailPreferences)
|
||||
r.Put("/email-preferences", notificationsHandler.HandleUpdateEmailPreferences)
|
||||
r.Put("/email-preferences/address", notificationsHandler.HandleRequestEmailAddress)
|
||||
r.Delete("/email-preferences/address", notificationsHandler.HandleClearEmailAddress)
|
||||
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 +2199,24 @@ 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 discordNotificationsHandler != nil {
|
||||
r.Post("/notifications/discord/test", discordNotificationsHandler.HandleAdminTest)
|
||||
}
|
||||
if deps.Notifications != nil && deps.Notifications.ServerChannels != nil {
|
||||
serverChannelsHandler := handlers.NewAdminServerChannelsHandler(deps.Notifications)
|
||||
r.Route("/notifications/server-channels", func(r chi.Router) {
|
||||
r.Get("/", serverChannelsHandler.HandleList)
|
||||
r.Post("/", serverChannelsHandler.HandleCreate)
|
||||
r.Put("/{id}", serverChannelsHandler.HandleUpdate)
|
||||
r.Delete("/{id}", serverChannelsHandler.HandleDelete)
|
||||
r.Post("/{id}/rotate-secret", serverChannelsHandler.HandleRotateSecret)
|
||||
r.Post("/{id}/test", serverChannelsHandler.HandleTest)
|
||||
})
|
||||
}
|
||||
if adminIntroHandler != nil {
|
||||
r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers)
|
||||
r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro)
|
||||
|
||||
@@ -91,6 +91,19 @@ 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,
|
||||
|
||||
// Discord notification integration. The client_id is public in Discord's
|
||||
// own UI, so only the secret and bot token are encrypted.
|
||||
"discord.client_secret": true,
|
||||
"discord.bot_token": 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
|
||||
@@ -127,6 +140,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) {
|
||||
|
||||
@@ -154,6 +154,10 @@ func TestSensitiveSettingKeys_Audited(t *testing.T) {
|
||||
"requests.radarr.api_key",
|
||||
"requests.sonarr.api_key",
|
||||
"watchsync.trakt.client_secret",
|
||||
"email.smtp_password",
|
||||
"discord.client_secret",
|
||||
"discord.bot_token",
|
||||
"notifications.web_push.vapid_keypair",
|
||||
}
|
||||
for _, k := range mustHave {
|
||||
if !SensitiveSettingKeys[k] {
|
||||
|
||||
@@ -29,6 +29,7 @@ const episodeCatalogSelectBody = `(
|
||||
COALESCE(e.tmdb_id, '') AS tmdb_id,
|
||||
COALESCE(e.tvdb_id, '') AS tvdb_id,
|
||||
COALESCE(NULLIF(s.poster_path, ''), NULLIF(si.poster_path, ''), NULLIF(e.still_path, ''), '') AS poster_path,
|
||||
''::text AS poster_source_path,
|
||||
COALESCE(NULLIF(s.poster_thumbhash, ''), NULLIF(si.poster_thumbhash, ''), NULLIF(e.still_thumbhash, ''), '') AS poster_thumbhash,
|
||||
COALESCE(si.backdrop_path, '') AS backdrop_path,
|
||||
COALESCE(si.backdrop_thumbhash, '') AS backdrop_thumbhash,
|
||||
|
||||
@@ -117,7 +117,7 @@ var itemColumnNames = []string{
|
||||
"content_rating", "runtime", "overview", "tagline",
|
||||
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
|
||||
"imdb_id", "tmdb_id", "tvdb_id",
|
||||
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
||||
"poster_path", "poster_source_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
||||
"metadata_s3_path", "metadata_etag", "season_count",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
|
||||
"show_status",
|
||||
@@ -130,6 +130,7 @@ var itemColumnNames = []string{
|
||||
// lists coalesce them to ”.
|
||||
var nullableStringItemColumns = map[string]bool{
|
||||
"poster_path": true,
|
||||
"poster_source_path": true,
|
||||
"poster_thumbhash": true,
|
||||
"backdrop_path": true,
|
||||
"backdrop_thumbhash": true,
|
||||
@@ -215,6 +216,7 @@ func scanItem(row pgx.Row) (*models.MediaItem, error) {
|
||||
&item.TmdbID,
|
||||
&item.TvdbID,
|
||||
&item.PosterPath,
|
||||
&item.PosterSourcePath,
|
||||
&item.PosterThumbhash,
|
||||
&item.BackdropPath,
|
||||
&item.BackdropThumbhash,
|
||||
@@ -278,6 +280,7 @@ func scanItems(rows pgx.Rows) ([]*models.MediaItem, error) {
|
||||
&item.TmdbID,
|
||||
&item.TvdbID,
|
||||
&item.PosterPath,
|
||||
&item.PosterSourcePath,
|
||||
&item.PosterThumbhash,
|
||||
&item.BackdropPath,
|
||||
&item.BackdropThumbhash,
|
||||
@@ -351,6 +354,7 @@ func scanItemsWithTotal(rows pgx.Rows) ([]*models.MediaItem, int, error) {
|
||||
&item.TmdbID,
|
||||
&item.TvdbID,
|
||||
&item.PosterPath,
|
||||
&item.PosterSourcePath,
|
||||
&item.PosterThumbhash,
|
||||
&item.BackdropPath,
|
||||
&item.BackdropThumbhash,
|
||||
@@ -417,7 +421,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo
|
||||
content_rating, runtime, overview, tagline,
|
||||
rating_imdb, rating_tmdb, rating_rt_critic, rating_rt_audience,
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
poster_path, poster_source_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
metadata_s3_path, metadata_etag, season_count,
|
||||
studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone,
|
||||
show_status,
|
||||
@@ -428,12 +432,12 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo
|
||||
$9, $10, $11, $12,
|
||||
$13, $14, $15, $16,
|
||||
$17, $18, $19,
|
||||
$20, $21, $22, $23, $24,
|
||||
$25, $26, $27,
|
||||
$28, $29, $30, $31, $32, $33, $34, $35, $36, $37,
|
||||
$38,
|
||||
$39, $40, $41,
|
||||
$42, $43, $44
|
||||
$20, $21, $22, $23, $24, $25,
|
||||
$26, $27, $28,
|
||||
$29, $30, $31, $32, $33, $34, $35, $36, $37, $38,
|
||||
$39,
|
||||
$40, $41, $42,
|
||||
$43, $44, $45
|
||||
)
|
||||
ON CONFLICT (content_id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
@@ -455,6 +459,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo
|
||||
tmdb_id = EXCLUDED.tmdb_id,
|
||||
tvdb_id = EXCLUDED.tvdb_id,
|
||||
poster_path = EXCLUDED.poster_path,
|
||||
poster_source_path = EXCLUDED.poster_source_path,
|
||||
poster_thumbhash = EXCLUDED.poster_thumbhash,
|
||||
backdrop_path = EXCLUDED.backdrop_path,
|
||||
backdrop_thumbhash = EXCLUDED.backdrop_thumbhash,
|
||||
@@ -502,6 +507,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo
|
||||
item.TmdbID,
|
||||
item.TvdbID,
|
||||
item.PosterPath,
|
||||
item.PosterSourcePath,
|
||||
item.PosterThumbhash,
|
||||
item.BackdropPath,
|
||||
item.BackdropThumbhash,
|
||||
@@ -1423,6 +1429,12 @@ func (r *ItemRepository) UpdateMetadata(ctx context.Context, contentID string, u
|
||||
addString("tvdb_id", upd.TvdbID)
|
||||
addIntArray("locked_fields", upd.LockedFields)
|
||||
addString("poster_path", upd.PosterPath)
|
||||
if upd.PosterPath != nil {
|
||||
// An explicit poster override invalidates the provider-origin source
|
||||
// path captured by image caching; outbound embeds must not keep
|
||||
// rendering the replaced provider artwork.
|
||||
setClauses = append(setClauses, "poster_source_path = NULL")
|
||||
}
|
||||
addString("poster_thumbhash", upd.PosterThumbhash)
|
||||
addString("backdrop_path", upd.BackdropPath)
|
||||
addString("backdrop_thumbhash", upd.BackdropThumbhash)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package discord is a minimal Discord REST client covering exactly what the
|
||||
// notification channel needs: the OAuth2 code exchange used for account
|
||||
// linking, identity lookups, and bot DM delivery. No Gateway connection is
|
||||
// held; everything is short-lived REST against a fixed trusted host.
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAPIBase = "https://discord.com/api/v10"
|
||||
// AuthorizeURL is the user-facing OAuth2 consent page.
|
||||
AuthorizeURL = "https://discord.com/oauth2/authorize"
|
||||
|
||||
requestTimeout = 10 * time.Second
|
||||
// Discord asks bot user agents to identify themselves in this format.
|
||||
userAgent = "DiscordBot (https://github.com/Silo-Server/silo-server, 1.0)"
|
||||
|
||||
// errorBodyLimit bounds how much of an error response is read for
|
||||
// diagnostics.
|
||||
errorBodyLimit = 4 << 10
|
||||
)
|
||||
|
||||
// Sentinel errors for the failure modes callers branch on.
|
||||
var (
|
||||
// ErrDMBlocked is Discord error 50007: the bot cannot DM this user. The
|
||||
// user does not share a guild with the bot or has server DMs disabled.
|
||||
ErrDMBlocked = errors.New("discord: cannot send messages to this user")
|
||||
// ErrUnauthorized means the bot token or OAuth credentials were rejected.
|
||||
ErrUnauthorized = errors.New("discord: unauthorized")
|
||||
// ErrRateLimited means Discord returned 429; retry later.
|
||||
ErrRateLimited = errors.New("discord: rate limited")
|
||||
)
|
||||
|
||||
const dmBlockedCode = 50007
|
||||
|
||||
// User is the subset of a Discord user object the integration stores.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Client makes Discord REST calls. Tokens are passed per call so callers can
|
||||
// read live settings; the client itself holds no credentials.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
apiBase string
|
||||
// limiter paces outbound calls well under Discord's global rate limits
|
||||
// (~50 req/s global, ~5 DMs/s); notification volume is far below this,
|
||||
// but digest-hour bursts across many accounts need smoothing.
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
// NewClient creates a Client.
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: requestTimeout},
|
||||
apiBase: defaultAPIBase,
|
||||
limiter: rate.NewLimiter(rate.Every(250*time.Millisecond), 4),
|
||||
}
|
||||
}
|
||||
|
||||
// ExchangeCode performs the OAuth2 authorization-code exchange and returns
|
||||
// the user access token. Only the identify scope is requested at authorize
|
||||
// time, so the token can do nothing beyond reading the user's own identity.
|
||||
func (c *Client) ExchangeCode(ctx context.Context, clientID, clientSecret, code, redirectURI string) (string, error) {
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.apiBase+"/oauth2/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(clientID, clientSecret)
|
||||
|
||||
var token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := c.do(req, &token); err != nil {
|
||||
return "", fmt.Errorf("exchange oauth code: %w", err)
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
return "", errors.New("discord: token response missing access_token")
|
||||
}
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
// GetUser returns the identity behind a user access token (GET /users/@me).
|
||||
func (c *Client) GetUser(ctx context.Context, accessToken string) (User, error) {
|
||||
return c.getMe(ctx, "Bearer "+accessToken)
|
||||
}
|
||||
|
||||
// GetBotUser returns the bot's own identity, verifying the bot token. Used by
|
||||
// the admin "test" endpoint.
|
||||
func (c *Client) GetBotUser(ctx context.Context, botToken string) (User, error) {
|
||||
return c.getMe(ctx, "Bot "+botToken)
|
||||
}
|
||||
|
||||
func (c *Client) getMe(ctx context.Context, authorization string) (User, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiBase+"/users/@me", nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", authorization)
|
||||
|
||||
var user User
|
||||
if err := c.do(req, &user); err != nil {
|
||||
return User{}, fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
if user.ID == "" {
|
||||
return User{}, errors.New("discord: user response missing id")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// OpenDMChannel opens (or returns the existing) DM channel with the user.
|
||||
// Idempotent: Discord returns the same channel for repeated calls.
|
||||
func (c *Client) OpenDMChannel(ctx context.Context, botToken, recipientDiscordUserID string) (string, error) {
|
||||
body, err := json.Marshal(map[string]string{"recipient_id": recipientDiscordUserID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.apiBase+"/users/@me/channels", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bot "+botToken)
|
||||
|
||||
var channel struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.do(req, &channel); err != nil {
|
||||
return "", fmt.Errorf("open dm channel: %w", err)
|
||||
}
|
||||
if channel.ID == "" {
|
||||
return "", errors.New("discord: channel response missing id")
|
||||
}
|
||||
return channel.ID, nil
|
||||
}
|
||||
|
||||
// SendDM posts a message payload (Discord message JSON, e.g. an embeds body)
|
||||
// to a DM channel.
|
||||
func (c *Client) SendDM(ctx context.Context, botToken, channelID string, payload []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.apiBase+"/channels/"+url.PathEscape(channelID)+"/messages", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bot "+botToken)
|
||||
|
||||
if err := c.do(req, nil); err != nil {
|
||||
return fmt.Errorf("send dm: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// do executes the request under the rate limiter and decodes a 2xx JSON
|
||||
// response into out (when non-nil). Non-2xx responses map to sentinel errors
|
||||
// with the Discord error message attached.
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
if err := c.limiter.Wait(req.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return apiError(resp)
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, errorBodyLimit))
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode discord response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiError maps a non-2xx response to a sentinel error, preserving Discord's
|
||||
// message for logs and UI surfacing.
|
||||
func apiError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, errorBodyLimit))
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
|
||||
switch {
|
||||
case payload.Code == dmBlockedCode:
|
||||
return ErrDMBlocked
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
return fmt.Errorf("%w: %s", ErrUnauthorized, payload.Message)
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
return ErrRateLimited
|
||||
}
|
||||
message := payload.Message
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(string(body))
|
||||
}
|
||||
return fmt.Errorf("discord: HTTP %d: %s", resp.StatusCode, message)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func testClient(t *testing.T, handler http.Handler) *Client {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
return &Client{
|
||||
httpClient: server.Client(),
|
||||
apiBase: server.URL,
|
||||
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeAndGetUser(t *testing.T) {
|
||||
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/oauth2/token":
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "client-id" || pass != "client-secret" {
|
||||
t.Errorf("missing or wrong basic auth: %s/%s", user, pass)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != "the-code" {
|
||||
t.Errorf("unexpected form: %v", r.PostForm)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "user-token"})
|
||||
case "/users/@me":
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer user-token" {
|
||||
t.Errorf("unexpected authorization %q", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(User{ID: "42", Username: "quick"})
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
|
||||
token, err := client.ExchangeCode(context.Background(), "client-id", "client-secret", "the-code", "https://silo.example/cb")
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
user, err := client.GetUser(context.Background(), token)
|
||||
if err != nil {
|
||||
t.Fatalf("get user: %v", err)
|
||||
}
|
||||
if user.ID != "42" || user.Username != "quick" {
|
||||
t.Fatalf("unexpected user %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDMChannelAndSendDM(t *testing.T) {
|
||||
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/users/@me/channels":
|
||||
if got := r.Header.Get("Authorization"); got != "Bot bot-token" {
|
||||
t.Errorf("unexpected authorization %q", got)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["recipient_id"] != "42" {
|
||||
t.Errorf("unexpected recipient %q", body["recipient_id"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": "dm-123"})
|
||||
case "/channels/dm-123/messages":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
|
||||
channelID, err := client.OpenDMChannel(context.Background(), "bot-token", "42")
|
||||
if err != nil {
|
||||
t.Fatalf("open dm: %v", err)
|
||||
}
|
||||
if channelID != "dm-123" {
|
||||
t.Fatalf("unexpected channel id %q", channelID)
|
||||
}
|
||||
if err := client.SendDM(context.Background(), "bot-token", channelID, []byte(`{"content":"hi"}`)); err != nil {
|
||||
t.Fatalf("send dm: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
want error
|
||||
}{
|
||||
{"dm blocked", http.StatusForbidden, `{"code":50007,"message":"Cannot send messages to this user"}`, ErrDMBlocked},
|
||||
{"bad token", http.StatusUnauthorized, `{"message":"401: Unauthorized"}`, ErrUnauthorized},
|
||||
{"rate limited", http.StatusTooManyRequests, `{"message":"You are being rate limited."}`, ErrRateLimited},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
err := client.SendDM(context.Background(), "bot-token", "dm-123", []byte(`{}`))
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterHonorsContextCancel(t *testing.T) {
|
||||
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
// Exhausted limiter with a long refill forces Wait to block on the context.
|
||||
client.limiter = rate.NewLimiter(rate.Every(time.Hour), 1)
|
||||
client.limiter.Allow()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := client.SendDM(ctx, "bot-token", "dm-123", []byte(`{}`)); err == nil {
|
||||
t.Fatal("expected context error from limiter wait")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,21 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode
|
||||
}
|
||||
}
|
||||
|
||||
// Content availability runs after matching/reconcile so releases are tied
|
||||
// to resolved items. 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 {
|
||||
kinds := notifications.AvailabilityKinds{
|
||||
Episodes: isTVLibraryType(folder.Type) || isMixedLibraryType(folder.Type),
|
||||
Movies: isMovieLibraryType(folder.Type) || isMixedLibraryType(folder.Type),
|
||||
}
|
||||
if kinds.Episodes || kinds.Movies {
|
||||
go e.availability.HandleIngestCompleted(scanCtx, folder.ID, mode == scopeModeLibrary, matchScopes, kinds)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldPublish(result) && e.events != nil {
|
||||
if err := e.events.Publish(scanCtx, cache.ChannelCatalog, cache.Event{
|
||||
Type: cache.EventScanComplete,
|
||||
@@ -446,6 +471,17 @@ func isMixedLibraryType(libraryType string) bool {
|
||||
return strings.ToLower(strings.TrimSpace(libraryType)) == "mixed"
|
||||
}
|
||||
|
||||
// isMovieLibraryType mirrors the scanner's movie library naming
|
||||
// (internal/scanner/scanner.go).
|
||||
func isMovieLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "movie", "movies":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) scan(ctx context.Context, folder *models.MediaFolder, mode scopeMode, scopePath string) ([]string, *scanner.ScanResult, error) {
|
||||
switch mode {
|
||||
case scopeModeLibrary:
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Shared visual tokens for Silo's branded emails, mirroring the web UI's
|
||||
// default "Midnight Cinema" theme (web/src/app.css): a near-black canvas,
|
||||
// monochrome type, and a white primary action. Feature packages compose body
|
||||
// fragments with these tokens and wrap them with RenderLayout so every email
|
||||
// the server sends looks like it came from the same product.
|
||||
//
|
||||
// Email-client constraints shape everything here: styles must be inline,
|
||||
// layout must be tables, and colors must be explicit on every element (no
|
||||
// inheritance through client-rewritten DOM). Web fonts don't load in most
|
||||
// clients, so the stacks lead with the brand font and degrade to common
|
||||
// system faces.
|
||||
const (
|
||||
EmailFont = "'Outfit','Avenir Next','Segoe UI',Helvetica,Arial,sans-serif"
|
||||
EmailFontMono = "'SF Mono',SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace"
|
||||
|
||||
EmailColorCanvas = "#141417" // page background
|
||||
EmailColorCard = "#1c1c20" // content card surface
|
||||
EmailColorBorder = "#2e2e35" // card outline
|
||||
EmailColorText = "#e8e8ec" // primary text
|
||||
EmailColorMuted = "#9696a0" // secondary text, badges, footer
|
||||
EmailColorRule = "#26262c" // hairline row separators
|
||||
EmailColorAction = "#e8e8ec" // primary button background (white-on-dark)
|
||||
EmailColorOnAct = "#141417" // primary button label
|
||||
)
|
||||
|
||||
// LayoutOptions is the content RenderLayout places into the branded shell.
|
||||
type LayoutOptions struct {
|
||||
// Preheader is the hidden inbox-preview snippet shown next to the subject
|
||||
// line. Plain text; optional.
|
||||
Preheader string
|
||||
// Title is the headline at the top of the card. Plain text; optional.
|
||||
Title string
|
||||
// BodyHTML is the card content below the title. Trusted HTML — callers
|
||||
// must escape any user-controlled values before building it.
|
||||
BodyHTML string
|
||||
// FooterHTML is the fine print under the card. Trusted HTML; optional.
|
||||
FooterHTML string
|
||||
}
|
||||
|
||||
// RenderLayout wraps content in Silo's dark branded email shell: wordmark,
|
||||
// content card, and footer. It adds no links of its own, so an email whose
|
||||
// options carry no hrefs renders fully link-free (some features require
|
||||
// that when no external URL is configured).
|
||||
func RenderLayout(opts LayoutOptions) string {
|
||||
preheader := ""
|
||||
if opts.Preheader != "" {
|
||||
// The trailing zwnj/nbsp run pads the preview so clients don't pull
|
||||
// body markup into the snippet after the real preheader text.
|
||||
preheader = `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">` +
|
||||
html.EscapeString(opts.Preheader) +
|
||||
strings.Repeat(" ‌", 40) + `</div>` + "\n"
|
||||
}
|
||||
title := ""
|
||||
if opts.Title != "" {
|
||||
title = `<h1 style="margin:0 0 16px;font:600 18px/1.4 ` + EmailFont +
|
||||
`;color:` + EmailColorText + `;">` + html.EscapeString(opts.Title) + `</h1>` + "\n"
|
||||
}
|
||||
footer := ""
|
||||
if opts.FooterHTML != "" {
|
||||
footer = `<tr><td style="padding:18px 6px 0;font:400 12px/1.7 ` + EmailFont +
|
||||
`;color:` + EmailColorMuted + `;">` + opts.FooterHTML + `</td></tr>` + "\n"
|
||||
}
|
||||
|
||||
return strings.NewReplacer(
|
||||
"{{preheader}}", preheader,
|
||||
"{{title}}", title,
|
||||
"{{body}}", opts.BodyHTML,
|
||||
"{{footer}}", footer,
|
||||
"{{font}}", EmailFont,
|
||||
"{{canvas}}", EmailColorCanvas,
|
||||
"{{card}}", EmailColorCard,
|
||||
"{{border}}", EmailColorBorder,
|
||||
"{{text}}", EmailColorText,
|
||||
).Replace(emailShell)
|
||||
}
|
||||
|
||||
// EmailButton renders the primary call-to-action: a white pill on the dark
|
||||
// card, matching the web UI's primary action style. Both arguments are
|
||||
// escaped here. The wrapping table keeps the button shape in Outlook, which
|
||||
// ignores padding on anchors.
|
||||
func EmailButton(label, href string) string {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>` +
|
||||
`<td bgcolor="` + EmailColorAction + `" style="background-color:` + EmailColorAction +
|
||||
`;border-radius:8px;mso-padding-alt:12px 24px;">` +
|
||||
`<a href="` + html.EscapeString(href) + `" style="display:inline-block;padding:12px 24px;` +
|
||||
`font:600 14px/1 ` + EmailFont + `;color:` + EmailColorOnAct +
|
||||
`;text-decoration:none;border-radius:8px;">` + html.EscapeString(label) + `</a>` +
|
||||
`</td></tr></table>`
|
||||
}
|
||||
|
||||
// EmailParagraph renders one body paragraph in the standard text style,
|
||||
// escaping the given plain text.
|
||||
func EmailParagraph(text string) string {
|
||||
return `<p style="margin:0 0 16px;font:400 14px/1.6 ` + EmailFont +
|
||||
`;color:` + EmailColorText + `;">` + html.EscapeString(text) + `</p>`
|
||||
}
|
||||
|
||||
// emailShell is the document skeleton. The color-scheme meta plus explicit
|
||||
// bgcolor attributes keep dark-mode-aware clients from inverting the design;
|
||||
// the small stylesheet only tightens padding on narrow screens (supported by
|
||||
// Gmail/Apple Mail, harmlessly ignored elsewhere).
|
||||
const emailShell = `<!DOCTYPE html>
|
||||
<html lang="en" style="color-scheme:dark;supported-color-schemes:dark;">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<style>
|
||||
@media (max-width: 480px) {
|
||||
.silo-shell { padding: 24px 12px 36px !important; }
|
||||
.silo-card { padding: 24px 20px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:{{canvas}};" bgcolor="{{canvas}}">
|
||||
{{preheader}}<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{{canvas}}" style="background-color:{{canvas}};">
|
||||
<tr><td align="center" class="silo-shell" style="padding:36px 16px 48px;">
|
||||
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:560px;">
|
||||
<tr><td style="padding:0 6px 18px;font:600 12px/1 {{font}};color:{{text}};letter-spacing:7px;"><span style="color:#55555e;">▸︎</span> SILO</td></tr>
|
||||
<tr><td class="silo-card" bgcolor="{{card}}" style="background-color:{{card}};border:1px solid {{border}};border-radius:12px;padding:28px 32px;">
|
||||
{{title}}{{body}}
|
||||
</td></tr>
|
||||
{{footer}}</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,52 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderLayoutEscapesAndPlacesContent(t *testing.T) {
|
||||
out := RenderLayout(LayoutOptions{
|
||||
Preheader: `sneak <script>alert(1)</script>`,
|
||||
Title: `Title & <b>bold</b>`,
|
||||
BodyHTML: `<p id="body-marker">trusted</p>`,
|
||||
FooterHTML: `<span id="footer-marker">fine print</span>`,
|
||||
})
|
||||
if strings.Contains(out, "<script>") || strings.Contains(out, "<b>bold</b>") {
|
||||
t.Fatalf("preheader/title not escaped:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Title & <b>bold</b>") {
|
||||
t.Fatalf("escaped title missing:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `<p id="body-marker">trusted</p>`) {
|
||||
t.Fatalf("body HTML not passed through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `<span id="footer-marker">fine print</span>`) {
|
||||
t.Fatalf("footer HTML not passed through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "SILO") {
|
||||
t.Fatalf("wordmark missing:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Some emails must render fully link-free when no external URL is configured;
|
||||
// the shell itself must therefore never contribute one.
|
||||
func TestRenderLayoutAddsNoLinks(t *testing.T) {
|
||||
out := RenderLayout(LayoutOptions{Title: "Hello", BodyHTML: "<p>hi</p>"})
|
||||
if strings.Contains(out, "href=") {
|
||||
t.Fatalf("layout shell added a link:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "<h1") && strings.Contains(RenderLayout(LayoutOptions{BodyHTML: "x"}), "<h1") {
|
||||
t.Fatalf("empty title should not render an <h1>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailButtonEscapes(t *testing.T) {
|
||||
out := EmailButton(`Click "here" <now>`, `https://example.com/?a=1&b=<2>`)
|
||||
if !strings.Contains(out, `href="https://example.com/?a=1&b=<2>"`) {
|
||||
t.Fatalf("href not escaped: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "<now>") {
|
||||
t.Fatalf("label not escaped: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// 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
|
||||
// Headers sets additional top-level headers (e.g. List-Unsubscribe).
|
||||
Headers map[string]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
|
||||
}
|
||||
// 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, err := s.settings.Get(ctx, key)
|
||||
if err != nil && readErr == nil {
|
||||
readErr = fmt.Errorf("read setting %s: %w", key, err)
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
enabled := truthy(get(SettingEnabled))
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if !enabled {
|
||||
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),
|
||||
}
|
||||
portRaw := get(SettingSMTPPort)
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if cfg.host == "" || cfg.fromAddress == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
for key, value := range msg.Headers {
|
||||
message.SetGenHeader(gomail.Header(key), value)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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: "<b>rich</b>",
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type stubImageCacher struct{}
|
||||
|
||||
func (stubImageCacher) CacheImage(_ context.Context, req CacheImageRequest) (*CacheImageResult, error) {
|
||||
return &CacheImageResult{
|
||||
BasePath: req.ProviderID + "/" + req.ContentType + "/" + req.ContentID + "/poster",
|
||||
Thumbhash: "hash",
|
||||
Ext: ".jpg",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Caching rewrites poster_path to a local storage key; the provider-origin
|
||||
// path must survive in PosterSourcePath so outbound notification embeds can
|
||||
// keep building public provider-CDN URLs.
|
||||
func TestCacheItemImagesKeepsPosterSourcePath(t *testing.T) {
|
||||
s := &MetadataService{imageCacher: stubImageCacher{}}
|
||||
item := &models.MediaItem{
|
||||
Type: "series",
|
||||
TmdbID: "95396",
|
||||
PosterPath: "https://image.tmdb.org/t/p/original/severance.jpg",
|
||||
}
|
||||
s.cacheItemImages(context.Background(), item, []RemoteImage{
|
||||
{URL: item.PosterPath, ProviderID: "tmdb", Type: ImagePoster},
|
||||
})
|
||||
if item.PosterSourcePath != "https://image.tmdb.org/t/p/original/severance.jpg" {
|
||||
t.Fatalf("provider poster path not preserved, got %q", item.PosterSourcePath)
|
||||
}
|
||||
if item.PosterPath != "tmdb/series/95396/poster/original.jpg" {
|
||||
t.Fatalf("poster path not rewritten to cached key, got %q", item.PosterPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Already-cached posters (bare storage keys) produce no cache job, so the
|
||||
// source path must not be overwritten and the caller's carry-forward applies.
|
||||
func TestCacheItemImagesSkipsBareKeys(t *testing.T) {
|
||||
s := &MetadataService{imageCacher: stubImageCacher{}}
|
||||
item := &models.MediaItem{
|
||||
Type: "series",
|
||||
TmdbID: "95396",
|
||||
PosterPath: "tmdb/series/95396/poster/original.jpg",
|
||||
}
|
||||
s.cacheItemImages(context.Background(), item, nil)
|
||||
if item.PosterSourcePath != "" {
|
||||
t.Fatalf("bare key must not produce a source path, got %q", item.PosterSourcePath)
|
||||
}
|
||||
if item.PosterPath != "tmdb/series/95396/poster/original.jpg" {
|
||||
t.Fatalf("bare key must pass through unchanged, got %q", item.PosterPath)
|
||||
}
|
||||
}
|
||||
@@ -1483,6 +1483,12 @@ func (s *MetadataService) mergeAndPersist(
|
||||
if s.autoCacheImages.Load() && s.imageCacher != nil && isCanonicalWrite {
|
||||
s.cacheItemImages(ctx, item, images)
|
||||
}
|
||||
// Refreshes that keep the already-cached poster (locked field, no new
|
||||
// candidates) never pass through cacheItemImages' source capture, so the
|
||||
// provider-origin path must survive from the existing row.
|
||||
if item.PosterSourcePath == "" && existingItem != nil && item.PosterPath == existingItem.PosterPath {
|
||||
item.PosterSourcePath = existingItem.PosterSourcePath
|
||||
}
|
||||
|
||||
if isNew && contentID == "" {
|
||||
var genErr error
|
||||
@@ -5174,6 +5180,12 @@ func (s *MetadataService) cacheItemImages(ctx context.Context, item *models.Medi
|
||||
|
||||
for cr := range results {
|
||||
j := jobs[cr.idx]
|
||||
if j.field.imageType == ImagePoster {
|
||||
// Keep the provider-origin path the cache rewrite erases below:
|
||||
// outbound notification embeds build public provider-CDN poster
|
||||
// URLs from it (local storage URLs never leave the server).
|
||||
item.PosterSourcePath = j.url
|
||||
}
|
||||
*j.field.path = cachedOriginalImagePath(cr.result.BasePath, cr.result.Ext)
|
||||
if j.field.thumbhash != nil && cr.result.Thumbhash != "" {
|
||||
*j.field.thumbhash = cr.result.Thumbhash
|
||||
|
||||
@@ -312,6 +312,7 @@ type MediaItem struct {
|
||||
TmdbID string
|
||||
TvdbID string
|
||||
PosterPath string // S3 path
|
||||
PosterSourcePath string // provider-origin path kept when caching rewrites PosterPath; feeds outbound embeds
|
||||
PosterThumbhash string
|
||||
BackdropPath string
|
||||
BackdropThumbhash string
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Channel modes shared by every watermark-sweep notification channel (email,
|
||||
// Discord). A channel is keyed by recipient: profiles for email (each profile
|
||||
// owns its address and watermark), login accounts for Discord (the linked
|
||||
// identity is account-level, and one send collapses cross-profile duplicates).
|
||||
const (
|
||||
ChannelModeOff = "off"
|
||||
ChannelModePerEpisode = "per_episode"
|
||||
ChannelModeDailyDigest = "daily_digest"
|
||||
// ChannelModePerEpisodeAndDigest sends per-episode all day and, at the
|
||||
// digest hour, a digest recapping everything since the previous digest —
|
||||
// including items already sent individually.
|
||||
ChannelModePerEpisodeAndDigest = "per_episode_and_digest"
|
||||
)
|
||||
|
||||
// ValidChannelMode reports whether mode is a recognized account-channel mode.
|
||||
func ValidChannelMode(mode string) bool {
|
||||
switch mode {
|
||||
case ChannelModeOff, ChannelModePerEpisode, ChannelModeDailyDigest,
|
||||
ChannelModePerEpisodeAndDigest:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ModeIncludesPerEpisode reports whether the mode performs per-episode sends
|
||||
// and is therefore subject to the admin per-episode allowance.
|
||||
func ModeIncludesPerEpisode(mode string) bool {
|
||||
return mode == ChannelModePerEpisode || mode == ChannelModePerEpisodeAndDigest
|
||||
}
|
||||
|
||||
const (
|
||||
channelPollInterval = time.Minute
|
||||
// channelNudgeDelay coalesces the per-row dispatch nudges of one fanout
|
||||
// batch (all rows commit before the first nudge fires) into one pass.
|
||||
channelNudgeDelay = 2 * time.Second
|
||||
// channelFetchLimit is the delivery read page size. Per-episode sends
|
||||
// stop after one page (the next pass drains the remainder); digest sends
|
||||
// page until the window is empty before stamping last_digest_at.
|
||||
channelFetchLimit = 200
|
||||
// channelMaxFailuresPerPass stops a pass early when sends keep failing —
|
||||
// transport trouble is almost always global, not per-recipient.
|
||||
channelMaxFailuresPerPass = 3
|
||||
|
||||
channelFailureBackoffBase = time.Minute
|
||||
channelFailureBackoffMax = 6 * time.Hour
|
||||
)
|
||||
|
||||
// errChannelUnavailable aborts a sweep pass entirely: the channel's transport
|
||||
// is unconfigured or globally down, so nothing else will send either. The
|
||||
// failing recipient is not penalized with backoff.
|
||||
var errChannelUnavailable = errors.New("notification channel unavailable")
|
||||
|
||||
// effectiveChannelMode coerces per-episode modes to the daily digest when the
|
||||
// admin has disallowed per-episode sends, instead of silencing those accounts.
|
||||
func effectiveChannelMode(mode string, allowPerEpisode bool) string {
|
||||
if ModeIncludesPerEpisode(mode) && !allowPerEpisode {
|
||||
return ChannelModeDailyDigest
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// cursorLess orders cursors the way the delivery queries do: by
|
||||
// (created_at, id).
|
||||
func cursorLess(a, b Cursor) bool {
|
||||
if !a.CreatedAt.Equal(b.CreatedAt) {
|
||||
return a.CreatedAt.Before(b.CreatedAt)
|
||||
}
|
||||
return a.ID < b.ID
|
||||
}
|
||||
|
||||
// maxCursor returns the later of two cursors. Watermark advancement clamps
|
||||
// with this so a watermark only ever moves forward.
|
||||
func maxCursor(a, b Cursor) Cursor {
|
||||
if cursorLess(a, b) {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// channelDigestDue reports whether a daily digest should go out: today's send
|
||||
// time (digestHour, local) has passed and no digest was stamped since.
|
||||
func channelDigestDue(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)
|
||||
}
|
||||
|
||||
// drainSince pages fetch from the given cursor until a short read, returning
|
||||
// every row in the window in delivery order.
|
||||
func drainSince(fetch func(since Cursor, limit int) ([]DeliveryRow, error), from Cursor) ([]DeliveryRow, error) {
|
||||
var all []DeliveryRow
|
||||
for {
|
||||
batch, err := fetch(from, channelFetchLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if len(batch) < channelFetchLimit {
|
||||
return all, nil
|
||||
}
|
||||
last := batch[len(batch)-1]
|
||||
from = Cursor{CreatedAt: last.CreatedAt, ID: last.ID}
|
||||
}
|
||||
}
|
||||
|
||||
// channelRetryEligible applies exponential backoff after failed sends:
|
||||
// 1m, 2m, 4m, ... capped at channelFailureBackoffMax.
|
||||
func channelRetryEligible(now time.Time, lastAttemptAt *time.Time, consecutiveFailures int) bool {
|
||||
if consecutiveFailures <= 0 || lastAttemptAt == nil {
|
||||
return true
|
||||
}
|
||||
backoff := channelFailureBackoffBase << min(consecutiveFailures-1, 30)
|
||||
if backoff > channelFailureBackoffMax || backoff <= 0 {
|
||||
backoff = channelFailureBackoffMax
|
||||
}
|
||||
return !now.Before(lastAttemptAt.Add(backoff))
|
||||
}
|
||||
|
||||
// accountRecipient is the channel-agnostic sweep state for one recipient: the
|
||||
// user-chosen mode plus the dispatch watermark and failure backoff counters.
|
||||
// K is the channel's recipient key — profile ID (string) for email, login
|
||||
// account ID (int) for Discord. Channel-specific contact details (email
|
||||
// address, Discord identity) stay inside the channel implementation.
|
||||
type accountRecipient[K comparable] struct {
|
||||
Key K
|
||||
Mode string
|
||||
WatermarkCreatedAt time.Time
|
||||
WatermarkID string
|
||||
LastDigestAt *time.Time
|
||||
LastAttemptAt *time.Time
|
||||
ConsecutiveFailures int
|
||||
}
|
||||
|
||||
// accountChannel supplies the channel-specific pieces of the watermark sweep:
|
||||
// prefs-table access, recipient-scoped delivery reads, and the actual send.
|
||||
// The engine owns the loop, eligibility, claim transaction, and watermark
|
||||
// advancement.
|
||||
type accountChannel[K comparable] interface {
|
||||
// name labels log lines.
|
||||
name() string
|
||||
// enabled gates a whole pass (kill switch + transport configured).
|
||||
enabled(ctx context.Context) bool
|
||||
// allowPerEpisode is the admin allowance for per-send mode.
|
||||
allowPerEpisode(ctx context.Context) bool
|
||||
// digestHour is the hour of day (0-23, server-local) for daily digests.
|
||||
digestHour(ctx context.Context) int
|
||||
// listRecipients returns every recipient with the channel switched on and
|
||||
// a usable destination. Disabled or deleted accounts must not appear.
|
||||
listRecipients(ctx context.Context) ([]accountRecipient[K], error)
|
||||
// hasPendingSince cheaply reports whether the recipient has deliveries
|
||||
// past the watermark, so idle recipients don't open a claim transaction
|
||||
// every pass.
|
||||
hasPendingSince(ctx context.Context, key K, since Cursor) (bool, error)
|
||||
// listSince returns the recipient's deliveries newer than the watermark,
|
||||
// ascending, inside the claim transaction. A non-zero until excludes rows
|
||||
// created at or after it (the digest window's exclusive upper edge).
|
||||
listSince(ctx context.Context, tx pgx.Tx, key K, since Cursor, until time.Time, limit int) ([]DeliveryRow, error)
|
||||
// claim locks the recipient's prefs row for one dispatch attempt with
|
||||
// FOR UPDATE SKIP LOCKED; (nil, nil) means another node holds the row.
|
||||
claim(ctx context.Context, tx pgx.Tx, key K) (*accountRecipient[K], error)
|
||||
// markSent advances the watermark past everything the send covered and
|
||||
// resets failure backoff. digestAt is non-nil for digest sends.
|
||||
markSent(ctx context.Context, tx pgx.Tx, key K, watermark Cursor, digestAt *time.Time) error
|
||||
// markFailure records a failed send for backoff; the watermark stays put
|
||||
// so the next eligible pass retries the same items.
|
||||
markFailure(ctx context.Context, tx pgx.Tx, key K, sendErr error) error
|
||||
// send delivers one recipient's pending rows. It runs inside the claim
|
||||
// transaction; tx is for channel-state updates only (the engine owns
|
||||
// commit/rollback). Errors wrapping errChannelUnavailable abort the pass
|
||||
// without penalizing the recipient.
|
||||
send(ctx context.Context, tx pgx.Tx, key K, mode string, rows []DeliveryRow) error
|
||||
}
|
||||
|
||||
// accountChannelWorker drives one watermark-sweep channel. Unlike webhooks
|
||||
// and web push it keeps no per-target outbox: deliveries already carry
|
||||
// user_id and profile_id, so a per-recipient watermark over
|
||||
// notification_deliveries is the durable dispatch state. The watermark
|
||||
// advances only after a successful send, and one send covers everything
|
||||
// since the last one.
|
||||
type accountChannelWorker[K comparable] struct {
|
||||
pool *pgxpool.Pool
|
||||
channel accountChannel[K]
|
||||
logger *slog.Logger
|
||||
nudge chan struct{}
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newAccountChannelWorker[K comparable](
|
||||
pool *pgxpool.Pool,
|
||||
channel accountChannel[K],
|
||||
) *accountChannelWorker[K] {
|
||||
return &accountChannelWorker[K]{
|
||||
pool: pool,
|
||||
channel: channel,
|
||||
logger: slog.Default().With("component", "notifications."+channel.name()),
|
||||
nudge: make(chan struct{}, 1),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Nudge schedules a near-term pass so per-episode sends follow fanout within
|
||||
// seconds instead of waiting for the next poll. Non-blocking.
|
||||
func (w *accountChannelWorker[K]) Nudge() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.nudge <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run sweeps eligible recipients until ctx is canceled.
|
||||
func (w *accountChannelWorker[K]) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(channelPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-w.nudge:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(channelNudgeDelay):
|
||||
}
|
||||
}
|
||||
if !w.channel.enabled(ctx) {
|
||||
continue
|
||||
}
|
||||
w.runPass(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// runPass attempts one send per eligible recipient. Failures back off per
|
||||
// recipient; the pass aborts entirely on errChannelUnavailable or after a few
|
||||
// consecutive failures, since both indicate a global transport problem.
|
||||
func (w *accountChannelWorker[K]) runPass(ctx context.Context) {
|
||||
recipients, err := w.channel.listRecipients(ctx)
|
||||
if err != nil {
|
||||
w.logger.Error("channel pass: list recipients failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
allowPerEpisode := w.channel.allowPerEpisode(ctx)
|
||||
digestHour := w.channel.digestHour(ctx)
|
||||
now := w.now()
|
||||
|
||||
failures := 0
|
||||
for _, rec := range recipients {
|
||||
if ctx.Err() != nil || failures >= channelMaxFailuresPerPass {
|
||||
return
|
||||
}
|
||||
if !channelRetryEligible(now, rec.LastAttemptAt, rec.ConsecutiveFailures) {
|
||||
continue
|
||||
}
|
||||
mode := effectiveChannelMode(rec.Mode, allowPerEpisode)
|
||||
digestDue := channelDigestDue(now, digestHour, rec.LastDigestAt)
|
||||
switch mode {
|
||||
case ChannelModePerEpisode, ChannelModePerEpisodeAndDigest:
|
||||
if mode == ChannelModePerEpisodeAndDigest && digestDue {
|
||||
break // the digest leg has work regardless of pending rows
|
||||
}
|
||||
// Cheap pre-check so idle recipients don't open a claim
|
||||
// transaction every pass. A stale watermark only ever
|
||||
// produces a harmless extra claim.
|
||||
pending, err := w.channel.hasPendingSince(ctx, rec.Key,
|
||||
Cursor{CreatedAt: rec.WatermarkCreatedAt, ID: rec.WatermarkID})
|
||||
if err != nil {
|
||||
w.logger.Warn("channel pass: pending check failed", "recipient", rec.Key, "error", err)
|
||||
continue
|
||||
}
|
||||
if !pending {
|
||||
continue
|
||||
}
|
||||
case ChannelModeDailyDigest:
|
||||
if !digestDue {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if err := w.processRecipient(ctx, rec); err != nil {
|
||||
if errors.Is(err, errChannelUnavailable) {
|
||||
return // channel turned off mid-pass; nothing else will send either
|
||||
}
|
||||
failures++
|
||||
w.logger.Warn("channel send failed", "recipient", rec.Key, "mode", mode, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processRecipient sends one recipient's pending notifications under the
|
||||
// prefs row lock. The send happens inside the claim transaction: the row lock
|
||||
// is per-recipient and only contends with other nodes, and committing the
|
||||
// watermark only after a successful send is what makes the channel durable.
|
||||
func (w *accountChannelWorker[K]) processRecipient(ctx context.Context, rec accountRecipient[K]) error {
|
||||
tx, err := w.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin channel dispatch tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
claimed, err := w.channel.claim(ctx, tx, rec.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if claimed == nil {
|
||||
return nil // another node is handling this recipient
|
||||
}
|
||||
|
||||
// Re-check the admin kill switch under the lock: a pass over many
|
||||
// recipients can outlive a settings flip, and disabling the channel must
|
||||
// stop in-flight sends, not just future passes.
|
||||
if !w.channel.enabled(ctx) {
|
||||
return fmt.Errorf("channel disabled: %w", errChannelUnavailable)
|
||||
}
|
||||
|
||||
// Re-derive eligibility from the locked row: the pre-scan snapshot may
|
||||
// predate a user mode flip or another node's digest stamp.
|
||||
mode := effectiveChannelMode(claimed.Mode, w.channel.allowPerEpisode(ctx))
|
||||
digestDue := channelDigestDue(w.now(), w.channel.digestHour(ctx), claimed.LastDigestAt)
|
||||
|
||||
// sendKind is the rendering the channel applies (per-episode alert vs
|
||||
// digest summary); for the combined mode it differs from the stored mode.
|
||||
sendKind := mode
|
||||
since := Cursor{CreatedAt: claimed.WatermarkCreatedAt, ID: claimed.WatermarkID}
|
||||
// fetchFrom is where this send reads rows from. Per-episode legs read
|
||||
// from the watermark (unsent rows); a combined-mode digest recaps the
|
||||
// whole window since the previous digest, which is usually behind the
|
||||
// watermark because its items already went out individually.
|
||||
fetchFrom := since
|
||||
var digestAt *time.Time
|
||||
|
||||
switch mode {
|
||||
case ChannelModePerEpisode:
|
||||
case ChannelModeDailyDigest:
|
||||
if !digestDue {
|
||||
return nil
|
||||
}
|
||||
now := w.now()
|
||||
digestAt = &now
|
||||
case ChannelModePerEpisodeAndDigest:
|
||||
if digestDue {
|
||||
sendKind = ChannelModeDailyDigest
|
||||
now := w.now()
|
||||
digestAt = &now
|
||||
if claimed.LastDigestAt != nil {
|
||||
// The empty cursor ID makes this lower bound inclusive of rows
|
||||
// created at exactly last_digest_at. The previous digest's
|
||||
// drain stopped strictly before that instant (the `until`
|
||||
// bound below), so consecutive digest windows partition rows
|
||||
// exactly: no boundary row is recapped twice or skipped.
|
||||
digestCursor := Cursor{CreatedAt: *claimed.LastDigestAt}
|
||||
if cursorLess(digestCursor, fetchFrom) {
|
||||
fetchFrom = digestCursor
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sendKind = ChannelModePerEpisode
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
// Digest reads stop strictly before the stamped digest time, so the next
|
||||
// digest's inclusive lower bound resumes exactly where this one ended.
|
||||
// Rows created mid-drain wait for the next per-episode pass or digest
|
||||
// window instead of straddling two windows.
|
||||
var until time.Time
|
||||
if digestAt != nil {
|
||||
until = *digestAt
|
||||
}
|
||||
fetch := func(since Cursor, limit int) ([]DeliveryRow, error) {
|
||||
return w.channel.listSince(ctx, tx, rec.Key, since, until, limit)
|
||||
}
|
||||
var rows []DeliveryRow
|
||||
if digestAt != nil {
|
||||
// Stamping last_digest_at closes the digest window — permanently for
|
||||
// the combined mode, until tomorrow for digest-only — so the digest
|
||||
// must drain the whole window, not stop at one page. Renderers cap
|
||||
// how many items they show, so a large drain stays deliverable.
|
||||
rows, err = drainSince(fetch, fetchFrom)
|
||||
} else {
|
||||
rows, err = fetch(fetchFrom, channelFetchLimit)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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.channel.markSent(ctx, tx, rec.Key, since, digestAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
items := rows
|
||||
if mode == ChannelModeDailyDigest {
|
||||
// The digest-only mode reports what the user hasn't seen; rows
|
||||
// already read in another client are skipped but the watermark still
|
||||
// passes them. The combined mode's digest deliberately recaps
|
||||
// everything — its per-episode sends already covered the new rows.
|
||||
items = make([]DeliveryRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.ReadAt == nil {
|
||||
items = append(items, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last := rows[len(rows)-1]
|
||||
// A combined-mode digest can read entirely behind the watermark; clamp so
|
||||
// the watermark only ever moves forward.
|
||||
watermark := maxCursor(Cursor{CreatedAt: last.CreatedAt, ID: last.ID}, since)
|
||||
|
||||
if len(items) > 0 {
|
||||
if err := w.channel.send(ctx, tx, rec.Key, sendKind, items); err != nil {
|
||||
if errors.Is(err, errChannelUnavailable) {
|
||||
return err
|
||||
}
|
||||
if markErr := w.channel.markFailure(ctx, tx, rec.Key, err); 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 sent",
|
||||
"recipient", rec.Key, "mode", mode, "items", len(items))
|
||||
}
|
||||
|
||||
if err := w.channel.markSent(ctx, tx, rec.Key, watermark, digestAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// nudger is the cross-key-type surface of accountChannelWorker the dispatch
|
||||
// path needs.
|
||||
type nudger interface {
|
||||
Nudge()
|
||||
}
|
||||
|
||||
// nudgeDispatcher plugs an account-channel 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 nudgeDispatcher struct {
|
||||
worker nudger
|
||||
}
|
||||
|
||||
func newNudgeDispatcher(worker nudger) *nudgeDispatcher {
|
||||
return &nudgeDispatcher{worker: worker}
|
||||
}
|
||||
|
||||
// Dispatch implements Dispatcher.
|
||||
func (d *nudgeDispatcher) Dispatch(_ context.Context, _ DeliveryRow) error {
|
||||
if d != nil {
|
||||
d.worker.Nudge()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeDeliveryWindow serves listSince-style paged reads over a fixed,
|
||||
// (created_at, id)-ordered dataset, mirroring the exclusive-cursor semantics
|
||||
// of the real delivery queries.
|
||||
type fakeDeliveryWindow struct {
|
||||
rows []DeliveryRow
|
||||
fetches int
|
||||
}
|
||||
|
||||
func (f *fakeDeliveryWindow) fetch(since Cursor, limit int) ([]DeliveryRow, error) {
|
||||
f.fetches++
|
||||
out := make([]DeliveryRow, 0, limit)
|
||||
for _, row := range f.rows {
|
||||
if !cursorLess(since, Cursor{CreatedAt: row.CreatedAt, ID: row.ID}) {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func makeDeliveryRows(n int) []DeliveryRow {
|
||||
base := time.Date(2026, 6, 11, 8, 0, 0, 0, time.UTC)
|
||||
rows := make([]DeliveryRow, n)
|
||||
for i := range rows {
|
||||
rows[i].ID = fmt.Sprintf("d%06d", i)
|
||||
rows[i].CreatedAt = base.Add(time.Duration(i) * time.Second)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func TestDrainSinceShortWindow(t *testing.T) {
|
||||
window := &fakeDeliveryWindow{rows: makeDeliveryRows(3)}
|
||||
got, err := drainSince(window.fetch, Cursor{})
|
||||
if err != nil {
|
||||
t.Fatalf("drainSince: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(got))
|
||||
}
|
||||
if window.fetches != 1 {
|
||||
t.Fatalf("expected 1 fetch for a short window, got %d", window.fetches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainSinceMultiplePages(t *testing.T) {
|
||||
// 2.5 pages: a single-page read would drop 300 rows from the digest.
|
||||
total := channelFetchLimit*2 + channelFetchLimit/2
|
||||
window := &fakeDeliveryWindow{rows: makeDeliveryRows(total)}
|
||||
|
||||
got, err := drainSince(window.fetch, Cursor{})
|
||||
if err != nil {
|
||||
t.Fatalf("drainSince: %v", err)
|
||||
}
|
||||
if len(got) != total {
|
||||
t.Fatalf("expected %d rows, got %d", total, len(got))
|
||||
}
|
||||
if window.fetches != 3 {
|
||||
t.Fatalf("expected 3 fetches, got %d", window.fetches)
|
||||
}
|
||||
for i, row := range got {
|
||||
if want := fmt.Sprintf("d%06d", i); row.ID != want {
|
||||
t.Fatalf("row %d out of order: got %s, want %s", i, row.ID, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainSinceExactPageBoundary(t *testing.T) {
|
||||
window := &fakeDeliveryWindow{rows: makeDeliveryRows(channelFetchLimit)}
|
||||
got, err := drainSince(window.fetch, Cursor{})
|
||||
if err != nil {
|
||||
t.Fatalf("drainSince: %v", err)
|
||||
}
|
||||
if len(got) != channelFetchLimit {
|
||||
t.Fatalf("expected %d rows, got %d", channelFetchLimit, len(got))
|
||||
}
|
||||
// A full first page can't prove the window is empty; the confirming
|
||||
// second fetch is expected.
|
||||
if window.fetches != 2 {
|
||||
t.Fatalf("expected 2 fetches, got %d", window.fetches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainSinceRespectsCursor(t *testing.T) {
|
||||
rows := makeDeliveryRows(10)
|
||||
window := &fakeDeliveryWindow{rows: rows}
|
||||
from := Cursor{CreatedAt: rows[6].CreatedAt, ID: rows[6].ID}
|
||||
|
||||
got, err := drainSince(window.fetch, from)
|
||||
if err != nil {
|
||||
t.Fatalf("drainSince: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 rows past the cursor, got %d", len(got))
|
||||
}
|
||||
if got[0].ID != rows[7].ID {
|
||||
t.Fatalf("expected first row %s, got %s", rows[7].ID, got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainSincePropagatesError(t *testing.T) {
|
||||
window := &fakeDeliveryWindow{rows: makeDeliveryRows(channelFetchLimit + 1)}
|
||||
wantErr := errors.New("boom")
|
||||
fetch := func(since Cursor, limit int) ([]DeliveryRow, error) {
|
||||
if window.fetches >= 1 {
|
||||
return nil, wantErr
|
||||
}
|
||||
return window.fetch(since, limit)
|
||||
}
|
||||
|
||||
if _, err := drainSince(fetch, Cursor{}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected fetch error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// AvailabilityKinds selects which content kinds an ingest scope covers.
|
||||
// Each kind keeps its own seed marker and silent-seeding semantics.
|
||||
type AvailabilityKinds struct {
|
||||
Episodes bool
|
||||
Movies bool
|
||||
}
|
||||
|
||||
// availabilityKindOps abstracts the per-kind recording calls so episode and
|
||||
// movie passes share one detection flow; seed state is kind-keyed in the
|
||||
// repository itself.
|
||||
type availabilityKindOps struct {
|
||||
kind string
|
||||
recordForLibrary func(ctx context.Context, libraryID int, emitEvents bool) (int, int, error)
|
||||
recordForPaths func(ctx context.Context, libraryID int, scopePaths []string, emitEvents bool) (int, int, error)
|
||||
}
|
||||
|
||||
// HandleIngestCompleted records newly available content 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, kinds AvailabilityKinds) {
|
||||
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()
|
||||
|
||||
if kinds.Episodes {
|
||||
d.runKind(detectCtx, libraryID, fullLibrary, scopePaths, availabilityKindOps{
|
||||
kind: EventKindEpisode,
|
||||
recordForLibrary: d.releases.RecordAvailabilityForLibrary,
|
||||
recordForPaths: d.releases.RecordAvailabilityForPaths,
|
||||
})
|
||||
}
|
||||
if kinds.Movies {
|
||||
d.runKind(detectCtx, libraryID, fullLibrary, scopePaths, availabilityKindOps{
|
||||
kind: EventKindMovie,
|
||||
recordForLibrary: d.releases.RecordMovieAvailabilityForLibrary,
|
||||
recordForPaths: d.releases.RecordMovieAvailabilityForPaths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (d *AvailabilityDetector) runKind(ctx context.Context, libraryID int, fullLibrary bool, scopePaths []string, ops availabilityKindOps) {
|
||||
seeded, err := d.releases.IsContentSeeded(ctx, libraryID, ops.kind)
|
||||
if err != nil {
|
||||
d.logger.Warn("seed state lookup failed",
|
||||
"library_id", libraryID, "kind", ops.kind, "error", err)
|
||||
return
|
||||
}
|
||||
emitEvents := seeded && d.settings.ReleaseEventsEnabled(ctx)
|
||||
|
||||
var inserted, events int
|
||||
if fullLibrary {
|
||||
inserted, events, err = ops.recordForLibrary(ctx, libraryID, emitEvents)
|
||||
} else if seeded {
|
||||
inserted, events, err = ops.recordForPaths(ctx, 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 = ops.recordForPaths(ctx, libraryID, scopePaths, false)
|
||||
}
|
||||
if err != nil {
|
||||
d.logger.Warn("availability detection failed",
|
||||
"library_id", libraryID, "kind", ops.kind, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if fullLibrary && !seeded {
|
||||
if err := d.releases.MarkContentSeeded(ctx, libraryID, ops.kind); err != nil {
|
||||
d.logger.Warn("seed marker write failed",
|
||||
"library_id", libraryID, "kind", ops.kind, "error", err)
|
||||
} else {
|
||||
d.logger.Info("library availability seeded",
|
||||
"library_id", libraryID, "kind", ops.kind, "availability_rows", inserted)
|
||||
}
|
||||
}
|
||||
|
||||
if inserted > 0 || events > 0 {
|
||||
d.logger.Info("availability recorded",
|
||||
"library_id", libraryID,
|
||||
"kind", ops.kind,
|
||||
"full_library", fullLibrary,
|
||||
"availability_rows", inserted,
|
||||
"release_events", events,
|
||||
)
|
||||
}
|
||||
if events > 0 && d.nudge != nil {
|
||||
d.nudge()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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,
|
||||
COALESCE(s.poster_source_path, '') AS poster_source_path,
|
||||
COALESCE(s.type, '') AS media_type,
|
||||
COALESCE(s.year, 0) AS year,
|
||||
COALESCE(s.overview, '') AS series_overview,
|
||||
COALESCE(e.overview, '') AS episode_overview,
|
||||
COALESCE(s.genres, '{}'::text[]) AS genres,
|
||||
COALESCE(s.content_rating, '') AS content_rating,
|
||||
COALESCE(s.rating_imdb, 0) AS rating_imdb,
|
||||
COALESCE(s.rating_tmdb, 0) AS rating_tmdb,
|
||||
COALESCE(s.imdb_id, '') AS imdb_id,
|
||||
COALESCE(s.tmdb_id, '') AS tmdb_id,
|
||||
COALESCE(s.tvdb_id, '') AS tvdb_id
|
||||
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, &row.PosterSourcePath,
|
||||
&row.MediaType, &row.Year, &row.SeriesOverview, &row.EpisodeOverview,
|
||||
&row.Genres, &row.ContentRating, &row.RatingIMDB, &row.RatingTMDB,
|
||||
&row.IMDBID, &row.TMDBID, &row.TVDBID,
|
||||
); 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
|
||||
}
|
||||
|
||||
// ListForUserSince returns the account's deliveries newer than the watermark,
|
||||
// ascending, across all of its profiles. A non-zero until excludes rows
|
||||
// created at or after it (digest window upper edge). Runs inside the channel
|
||||
// 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, until time.Time, limit int) ([]DeliveryRow, error) {
|
||||
query := deliveryRowSelect + `
|
||||
WHERE d.user_id = $1 AND (d.created_at, d.id) > ($2, $3)`
|
||||
args := []any{userID, since.CreatedAt, since.ID}
|
||||
if !until.IsZero() {
|
||||
args = append(args, until)
|
||||
query += fmt.Sprintf(" AND d.created_at < $%d", len(args))
|
||||
}
|
||||
args = append(args, limit)
|
||||
query += fmt.Sprintf(" ORDER BY d.created_at ASC, d.id ASC LIMIT $%d", len(args))
|
||||
rows, err := tx.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user deliveries since: %w", err)
|
||||
}
|
||||
return scanDeliveryRows(rows)
|
||||
}
|
||||
|
||||
// HasForUserSince reports whether the account has any delivery newer than the
|
||||
// given watermark. Cheap pre-check (index-only) so account-channel sweeps do
|
||||
// not open a claim transaction for idle accounts every pass.
|
||||
func (r *DeliveryRepository) HasForUserSince(ctx context.Context, userID int, since Cursor) (bool, error) {
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_deliveries
|
||||
WHERE user_id = $1 AND (created_at, id) > ($2, $3)
|
||||
)`,
|
||||
userID, since.CreatedAt, since.ID,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check deliveries since watermark: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ListForProfileSince returns the profile's deliveries newer than the
|
||||
// watermark, ascending. A non-zero until excludes rows created at or after it
|
||||
// (digest window upper edge). Runs inside the channel worker's claim
|
||||
// transaction so the rows read are the rows the advanced watermark covers.
|
||||
func (r *DeliveryRepository) ListForProfileSince(ctx context.Context, tx pgx.Tx, profileID string, since Cursor, until time.Time, limit int) ([]DeliveryRow, error) {
|
||||
query := deliveryRowSelect + `
|
||||
WHERE d.profile_id = $1 AND (d.created_at, d.id) > ($2, $3)`
|
||||
args := []any{profileID, since.CreatedAt, since.ID}
|
||||
if !until.IsZero() {
|
||||
args = append(args, until)
|
||||
query += fmt.Sprintf(" AND d.created_at < $%d", len(args))
|
||||
}
|
||||
args = append(args, limit)
|
||||
query += fmt.Sprintf(" ORDER BY d.created_at ASC, d.id ASC LIMIT $%d", len(args))
|
||||
rows, err := tx.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list profile deliveries since: %w", err)
|
||||
}
|
||||
return scanDeliveryRows(rows)
|
||||
}
|
||||
|
||||
// HasForProfileSince reports whether the profile has any delivery newer than
|
||||
// the given watermark. Cheap pre-check (index-only) so account-channel sweeps
|
||||
// do not open a claim transaction for idle profiles every pass.
|
||||
func (r *DeliveryRepository) HasForProfileSince(ctx context.Context, profileID string, since Cursor) (bool, error) {
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_deliveries
|
||||
WHERE profile_id = $1 AND (created_at, id) > ($2, $3)
|
||||
)`,
|
||||
profileID, since.CreatedAt, since.ID,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check profile deliveries since watermark: %w", err)
|
||||
}
|
||||
return exists, 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: 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 read_at < $1)
|
||||
OR (read_at IS NULL AND created_at < $2)`,
|
||||
readCutoff, unreadCutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/discord"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// discordDMBlockedMessage is the link_failure text surfaced in the settings
|
||||
// UI when Discord refuses the DM (error 50007).
|
||||
const discordDMBlockedMessage = "Discord rejected the direct message. " +
|
||||
"Make sure you share a server with the bot and allow direct messages from server members."
|
||||
|
||||
// discordChannel implements accountChannel over the Discord bot REST API,
|
||||
// keyed by login account: the linked identity is account-level, so one DM
|
||||
// collapses cross-profile duplicates. The bot token is read live from
|
||||
// settings on every pass, so admin changes apply without a restart (same
|
||||
// pattern as the SMTP sender).
|
||||
type discordChannel struct {
|
||||
prefs *DiscordPrefsRepository
|
||||
deliveries *DeliveryRepository
|
||||
settings *Settings
|
||||
client *discord.Client
|
||||
// posterURL picks the artwork URL DM embeds may carry. Wired by
|
||||
// NewSystem after construction; nil renders embeds without images.
|
||||
posterURL func(ctx context.Context, posterPath, posterSourcePath string) string
|
||||
}
|
||||
|
||||
// The assertion also keeps staticcheck's unused-analysis aware that the
|
||||
// adapter methods are consumed through the generic engine interface.
|
||||
var _ accountChannel[int] = (*discordChannel)(nil)
|
||||
|
||||
// newDiscordWorker assembles the Discord DM channel on the shared
|
||||
// account-channel engine, returning the channel too so NewSystem can wire
|
||||
// post-construction hooks (posterURL) on it.
|
||||
func newDiscordWorker(
|
||||
pool *pgxpool.Pool,
|
||||
deliveries *DeliveryRepository,
|
||||
prefs *DiscordPrefsRepository,
|
||||
settings *Settings,
|
||||
client *discord.Client,
|
||||
) (*accountChannelWorker[int], *discordChannel) {
|
||||
channel := &discordChannel{
|
||||
prefs: prefs,
|
||||
deliveries: deliveries,
|
||||
settings: settings,
|
||||
client: client,
|
||||
}
|
||||
return newAccountChannelWorker(pool, channel), channel
|
||||
}
|
||||
|
||||
func (c *discordChannel) name() string { return "discord" }
|
||||
|
||||
func (c *discordChannel) enabled(ctx context.Context) bool {
|
||||
return c.settings.DiscordEnabled(ctx) && c.settings.DiscordBotToken(ctx) != ""
|
||||
}
|
||||
|
||||
func (c *discordChannel) allowPerEpisode(ctx context.Context) bool {
|
||||
return c.settings.DiscordAllowPerEpisode(ctx)
|
||||
}
|
||||
|
||||
func (c *discordChannel) digestHour(ctx context.Context) int {
|
||||
return c.settings.DiscordDigestHour(ctx)
|
||||
}
|
||||
|
||||
func (c *discordChannel) listRecipients(ctx context.Context) ([]accountRecipient[int], error) {
|
||||
return c.prefs.ListActiveRecipients(ctx)
|
||||
}
|
||||
|
||||
func (c *discordChannel) hasPendingSince(ctx context.Context, userID int, since Cursor) (bool, error) {
|
||||
return c.deliveries.HasForUserSince(ctx, userID, since)
|
||||
}
|
||||
|
||||
func (c *discordChannel) listSince(ctx context.Context, tx pgx.Tx, userID int, since Cursor, until time.Time, limit int) ([]DeliveryRow, error) {
|
||||
return c.deliveries.ListForUserSince(ctx, tx, userID, since, until, limit)
|
||||
}
|
||||
|
||||
func (c *discordChannel) claim(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient[int], error) {
|
||||
return c.prefs.claimForUpdate(ctx, tx, userID)
|
||||
}
|
||||
|
||||
func (c *discordChannel) markSent(ctx context.Context, tx pgx.Tx, userID int, watermark Cursor, digestAt *time.Time) error {
|
||||
return c.prefs.markSent(ctx, tx, userID, watermark, digestAt)
|
||||
}
|
||||
|
||||
func (c *discordChannel) markFailure(ctx context.Context, tx pgx.Tx, userID int, sendErr error) error {
|
||||
message := "Discord delivery failed"
|
||||
switch {
|
||||
case errors.Is(sendErr, discord.ErrDMBlocked):
|
||||
message = discordDMBlockedMessage
|
||||
case sendErr != nil:
|
||||
message = truncateWithEllipsis("Discord delivery failed: "+sendErr.Error(), 300)
|
||||
}
|
||||
return c.prefs.markFailure(ctx, tx, userID, message)
|
||||
}
|
||||
|
||||
// send delivers one account's pending rows as a single bot DM. Failures that
|
||||
// indicate a global problem (missing/rejected bot token, rate limiting) wrap
|
||||
// errChannelUnavailable so the pass aborts without penalizing the account;
|
||||
// everything else (notably 50007 DM-blocked) backs off per account and is
|
||||
// surfaced as link health.
|
||||
func (c *discordChannel) send(ctx context.Context, tx pgx.Tx, userID int, _ string, rows []DeliveryRow) error {
|
||||
botToken := c.settings.DiscordBotToken(ctx)
|
||||
if botToken == "" {
|
||||
return fmt.Errorf("bot token not configured: %w", errChannelUnavailable)
|
||||
}
|
||||
|
||||
discordUserID, dmChannelID, err := c.prefs.identityForSend(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discordUserID == "" {
|
||||
return fmt.Errorf("account %d has no linked discord identity", userID)
|
||||
}
|
||||
if dmChannelID == "" {
|
||||
dmChannelID, err = c.client.OpenDMChannel(ctx, botToken, discordUserID)
|
||||
if err != nil {
|
||||
return classifyDiscordSendError(err)
|
||||
}
|
||||
if err := c.prefs.cacheDMChannel(ctx, tx, userID, dmChannelID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if c.posterURL != nil {
|
||||
for i := range rows {
|
||||
rows[i].PosterURL = c.posterURL(ctx, rows[i].PosterPath, rows[i].PosterSourcePath)
|
||||
}
|
||||
}
|
||||
payload, err := BuildDiscordDMPayload(rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build discord dm payload: %w", err)
|
||||
}
|
||||
if err := c.client.SendDM(ctx, botToken, dmChannelID, payload); err != nil {
|
||||
return classifyDiscordSendError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyDiscordSendError separates global transport problems (which abort
|
||||
// the pass) from per-account failures (which back off and surface as link
|
||||
// health).
|
||||
func classifyDiscordSendError(err error) error {
|
||||
if errors.Is(err, discord.ErrUnauthorized) || errors.Is(err, discord.ErrRateLimited) {
|
||||
return fmt.Errorf("%w: %w", err, errChannelUnavailable)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Errors surfaced by the Discord System methods for the API layer to map to
|
||||
// 4xx responses.
|
||||
var (
|
||||
ErrDiscordModeInvalid = errors.New("invalid discord notification mode")
|
||||
ErrDiscordModeNotAllowed = errors.New("per-episode discord DMs are disabled by the administrator")
|
||||
ErrDiscordNotLinked = errors.New("no linked discord account")
|
||||
ErrDiscordNotConfigured = errors.New("discord integration is not configured")
|
||||
)
|
||||
|
||||
// discordLinkStateTTL bounds how long a started link flow stays redeemable.
|
||||
const discordLinkStateTTL = 10 * time.Minute
|
||||
|
||||
// DiscordConfigured reports whether the admin has supplied the full Discord
|
||||
// application credential set (linking needs client ID + secret; DM delivery
|
||||
// needs the bot token).
|
||||
func (s *System) DiscordConfigured(ctx context.Context) bool {
|
||||
return s != nil && s.DiscordPrefs != nil &&
|
||||
s.Settings.DiscordClientID(ctx) != "" &&
|
||||
s.Settings.DiscordClientSecret(ctx) != "" &&
|
||||
s.Settings.DiscordBotToken(ctx) != ""
|
||||
}
|
||||
|
||||
// DiscordAvailable reports whether the Discord DM channel can deliver right
|
||||
// now: fully configured and the kill switch is on.
|
||||
func (s *System) DiscordAvailable(ctx context.Context) bool {
|
||||
return s.DiscordConfigured(ctx) && s.Settings.DiscordEnabled(ctx)
|
||||
}
|
||||
|
||||
// DiscordPrefsFor returns the account's Discord link + mode state.
|
||||
func (s *System) DiscordPrefsFor(ctx context.Context, userID int) (DiscordPrefs, error) {
|
||||
if s == nil || s.DiscordPrefs == nil {
|
||||
return DiscordPrefs{UserID: userID, Mode: ChannelModeOff}, nil
|
||||
}
|
||||
return s.DiscordPrefs.Get(ctx, userID)
|
||||
}
|
||||
|
||||
// SetDiscordMode validates and stores the account's Discord DM mode.
|
||||
// Enabling requires a linked Discord account and, for per-episode, the admin
|
||||
// allowance.
|
||||
func (s *System) SetDiscordMode(ctx context.Context, userID int, mode string) error {
|
||||
if s == nil || s.DiscordPrefs == nil {
|
||||
return ErrDiscordModeInvalid
|
||||
}
|
||||
if !ValidChannelMode(mode) {
|
||||
return ErrDiscordModeInvalid
|
||||
}
|
||||
if ModeIncludesPerEpisode(mode) && !s.Settings.DiscordAllowPerEpisode(ctx) {
|
||||
return ErrDiscordModeNotAllowed
|
||||
}
|
||||
return s.DiscordPrefs.SetMode(ctx, userID, mode)
|
||||
}
|
||||
|
||||
// BeginDiscordLink records a one-time state row for a link flow started by
|
||||
// userID.
|
||||
func (s *System) BeginDiscordLink(ctx context.Context, state string, userID int) error {
|
||||
if s == nil || s.DiscordPrefs == nil {
|
||||
return ErrDiscordNotConfigured
|
||||
}
|
||||
return s.DiscordPrefs.CreateLinkState(ctx, state, userID, time.Now().UTC().Add(discordLinkStateTTL))
|
||||
}
|
||||
|
||||
// ConsumeDiscordLinkState redeems a one-time link state, returning the user
|
||||
// who started the flow. ok is false for unknown, used, or expired states.
|
||||
func (s *System) ConsumeDiscordLinkState(ctx context.Context, state string) (int, bool, error) {
|
||||
if s == nil || s.DiscordPrefs == nil {
|
||||
return 0, false, ErrDiscordNotConfigured
|
||||
}
|
||||
return s.DiscordPrefs.ConsumeLinkState(ctx, state)
|
||||
}
|
||||
|
||||
// CompleteDiscordLink exchanges the OAuth authorization code, resolves the
|
||||
// Discord identity behind it, and links it to the account.
|
||||
func (s *System) CompleteDiscordLink(ctx context.Context, userID int, code, redirectURI string) (discord.User, error) {
|
||||
if s == nil || s.discordClient == nil || s.DiscordPrefs == nil {
|
||||
return discord.User{}, ErrDiscordNotConfigured
|
||||
}
|
||||
clientID := s.Settings.DiscordClientID(ctx)
|
||||
clientSecret := s.Settings.DiscordClientSecret(ctx)
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return discord.User{}, ErrDiscordNotConfigured
|
||||
}
|
||||
accessToken, err := s.discordClient.ExchangeCode(ctx, clientID, clientSecret, code, redirectURI)
|
||||
if err != nil {
|
||||
return discord.User{}, err
|
||||
}
|
||||
user, err := s.discordClient.GetUser(ctx, accessToken)
|
||||
if err != nil {
|
||||
return discord.User{}, err
|
||||
}
|
||||
if err := s.DiscordPrefs.SetIdentity(ctx, userID, user.ID, user.Username); err != nil {
|
||||
return discord.User{}, err
|
||||
}
|
||||
s.logger.Info("discord account linked", "user_id", userID, "discord_user_id", user.ID)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UnlinkDiscord removes the account's Discord identity and switches the
|
||||
// channel off.
|
||||
func (s *System) UnlinkDiscord(ctx context.Context, userID int) error {
|
||||
if s == nil || s.DiscordPrefs == nil {
|
||||
return nil
|
||||
}
|
||||
return s.DiscordPrefs.ClearIdentity(ctx, userID)
|
||||
}
|
||||
|
||||
// TestDiscordBot verifies the configured bot token by fetching the bot's own
|
||||
// identity. Used by the admin test endpoint.
|
||||
func (s *System) TestDiscordBot(ctx context.Context) (discord.User, error) {
|
||||
if s == nil || s.discordClient == nil {
|
||||
return discord.User{}, ErrDiscordNotConfigured
|
||||
}
|
||||
// The admin typically tests seconds after saving; don't let the read
|
||||
// cache report a stale "not configured".
|
||||
s.Settings.Invalidate(SettingDiscordBotToken)
|
||||
botToken := s.Settings.DiscordBotToken(ctx)
|
||||
if botToken == "" {
|
||||
return discord.User{}, ErrDiscordNotConfigured
|
||||
}
|
||||
return s.discordClient.GetBotUser(ctx, botToken)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Discord embed metadata helpers shared by the per-profile webhook/DM
|
||||
// builders and the server-channel builders. Everything here is pure and may
|
||||
// only ever emit public provider origins (TMDB, IMDb, TVDB and their image
|
||||
// CDNs) — never the user's own server origin (docs/superpowers/plans/
|
||||
// notifications/04, "Server URL leakage").
|
||||
|
||||
// discordOverviewLimit clips overviews well below Discord's 4096-char
|
||||
// description cap: a full synopsis crowds the embed; a teaser reads better.
|
||||
const discordOverviewLimit = 350
|
||||
|
||||
// Media-type discriminator values shared by catalog rows (media_items.type)
|
||||
// and request payloads.
|
||||
const (
|
||||
mediaTypeMovie = "movie"
|
||||
mediaTypeSeries = "series"
|
||||
)
|
||||
|
||||
// providerIDs carries the external database identifiers an embed can link to.
|
||||
// MediaType distinguishes the movie/series URL forms ("movie" | "series").
|
||||
type providerIDs struct {
|
||||
MediaType string
|
||||
IMDB string
|
||||
TMDB string
|
||||
TVDB string
|
||||
}
|
||||
|
||||
func (ids providerIDs) tmdbURL() string {
|
||||
if ids.TMDB == "" {
|
||||
return ""
|
||||
}
|
||||
kind := mediaTypeMovie
|
||||
if ids.MediaType == mediaTypeSeries {
|
||||
kind = "tv"
|
||||
}
|
||||
return "https://www.themoviedb.org/" + kind + "/" + ids.TMDB
|
||||
}
|
||||
|
||||
func (ids providerIDs) imdbURL() string {
|
||||
if ids.IMDB == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://www.imdb.com/title/" + ids.IMDB + "/"
|
||||
}
|
||||
|
||||
func (ids providerIDs) tvdbURL() string {
|
||||
if ids.TVDB == "" {
|
||||
return ""
|
||||
}
|
||||
kind := mediaTypeMovie
|
||||
if ids.MediaType == mediaTypeSeries {
|
||||
kind = mediaTypeSeries
|
||||
}
|
||||
return "https://thetvdb.com/dereferrer/" + kind + "/" + ids.TVDB
|
||||
}
|
||||
|
||||
// titleURL picks the embed title's click-through link, preferring TMDB (the
|
||||
// richest public page for both movies and series).
|
||||
func (ids providerIDs) titleURL() string {
|
||||
for _, url := range []string{ids.tmdbURL(), ids.imdbURL(), ids.tvdbURL()} {
|
||||
if url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// linkLine renders the external database links as one markdown line:
|
||||
// "[TMDB](…) • [IMDb](…) • [TVDB](…)". Empty when no IDs are known.
|
||||
func (ids providerIDs) linkLine() string {
|
||||
links := make([]string, 0, 3)
|
||||
if url := ids.tmdbURL(); url != "" {
|
||||
links = append(links, "[TMDB]("+url+")")
|
||||
}
|
||||
if url := ids.imdbURL(); url != "" {
|
||||
links = append(links, "[IMDb]("+url+")")
|
||||
}
|
||||
if url := ids.tvdbURL(); url != "" {
|
||||
links = append(links, "[TVDB]("+url+")")
|
||||
}
|
||||
return strings.Join(links, " • ")
|
||||
}
|
||||
|
||||
// publicArtworkURL maps a stored artwork path to a public provider CDN URL,
|
||||
// or "" when no such URL exists. Plugin-scheme paths mirror the plugins'
|
||||
// own resolvers; verbatim http(s) paths are provider-supplied external URLs.
|
||||
// Locally cached artwork (bare storage keys) deliberately yields "" so embeds
|
||||
// never name the server's storage origin.
|
||||
func publicArtworkURL(path string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "tmdb://"):
|
||||
// tmdb://poster/abc.jpg → CDN file abc.jpg; w500 suits thumbnails.
|
||||
if _, file, ok := strings.Cut(strings.TrimPrefix(path, "tmdb://"), "/"); ok && file != "" {
|
||||
return "https://image.tmdb.org/t/p/w500/" + file
|
||||
}
|
||||
return ""
|
||||
case strings.HasPrefix(path, "tvdb://"):
|
||||
return "https://artworks.thetvdb.com/" + strings.TrimPrefix(path, "tvdb://")
|
||||
case strings.HasPrefix(path, "http://"), strings.HasPrefix(path, "https://"):
|
||||
return path
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// embedPosterURL resolves the best public poster URL for an embed: the
|
||||
// stored poster path when it is provider-origin, else the provider source
|
||||
// path preserved by image caching (empty when neither resolves publicly).
|
||||
func embedPosterURL(posterPath, posterSourcePath string) string {
|
||||
if url := publicArtworkURL(posterPath); url != "" {
|
||||
return url
|
||||
}
|
||||
return publicArtworkURL(posterSourcePath)
|
||||
}
|
||||
|
||||
// tmdbRawImageURL renders a raw TMDB image path ("/abc.jpg", as stored on
|
||||
// media requests) as a public CDN URL.
|
||||
func tmdbRawImageURL(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return "https://image.tmdb.org/t/p/w500" + path
|
||||
}
|
||||
|
||||
// ratingLabel renders "★ 8.4 IMDb", preferring IMDb's score; "" when neither
|
||||
// rating is known.
|
||||
func ratingLabel(imdb, tmdb float64) string {
|
||||
switch {
|
||||
case imdb > 0:
|
||||
return fmt.Sprintf("★ %.1f IMDb", imdb)
|
||||
case tmdb > 0:
|
||||
return fmt.Sprintf("★ %.1f TMDB", tmdb)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// genresLabel renders up to three genres as a comma-separated line.
|
||||
func genresLabel(genres []string) string {
|
||||
kept := make([]string, 0, 3)
|
||||
for _, genre := range genres {
|
||||
if genre = strings.TrimSpace(genre); genre != "" {
|
||||
kept = append(kept, genre)
|
||||
}
|
||||
if len(kept) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, ", ")
|
||||
}
|
||||
|
||||
// overviewSnippet clips an overview to the embed teaser length, ending on a
|
||||
// word boundary so the cut reads naturally.
|
||||
func overviewSnippet(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) <= discordOverviewLimit {
|
||||
return text
|
||||
}
|
||||
clipped := strings.TrimSuffix(truncateWithEllipsis(text, discordOverviewLimit), "…")
|
||||
if at := strings.LastIndexByte(clipped, ' '); at > discordOverviewLimit/2 {
|
||||
clipped = clipped[:at]
|
||||
}
|
||||
return strings.TrimRight(clipped, " ,.;:") + "…"
|
||||
}
|
||||
|
||||
// titleWithYear appends the release year when known: "Dune (2021)".
|
||||
func titleWithYear(title string, year int) string {
|
||||
if year > 0 {
|
||||
return fmt.Sprintf("%s (%d)", title, year)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// embedDescription joins the overview teaser and the provider link line.
|
||||
func embedDescription(overview string, ids providerIDs) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if snippet := overviewSnippet(overview); snippet != "" {
|
||||
parts = append(parts, snippet)
|
||||
}
|
||||
if links := ids.linkLine(); links != "" {
|
||||
parts = append(parts, links)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublicArtworkURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"tmdb poster scheme", "tmdb://poster/abc.jpg", "https://image.tmdb.org/t/p/w500/abc.jpg"},
|
||||
{"tmdb backdrop scheme", "tmdb://backdrop/bd.jpg", "https://image.tmdb.org/t/p/w500/bd.jpg"},
|
||||
{"tmdb malformed", "tmdb://abc.jpg", ""},
|
||||
{"tvdb scheme", "tvdb://banners/posters/x.jpg", "https://artworks.thetvdb.com/banners/posters/x.jpg"},
|
||||
{"verbatim https", "https://example-provider.com/p.jpg", "https://example-provider.com/p.jpg"},
|
||||
// Locally cached artwork must never resolve: a storage URL would name
|
||||
// the server's own origin in an outbound payload.
|
||||
{"cached storage key", "tmdb/movies/550/poster/original.jpg", ""},
|
||||
{"unknown plugin scheme", "metadb://poster/x.jpg", ""},
|
||||
{"legacy dash", "-", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := publicArtworkURL(tc.path); got != tc.want {
|
||||
t.Errorf("%s: publicArtworkURL(%q) = %q, want %q", tc.name, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderIDsURLs(t *testing.T) {
|
||||
movie := providerIDs{MediaType: "movie", IMDB: "tt1160419", TMDB: "438631", TVDB: "290"}
|
||||
if got := movie.tmdbURL(); got != "https://www.themoviedb.org/movie/438631" {
|
||||
t.Fatalf("unexpected movie tmdb URL %q", got)
|
||||
}
|
||||
if got := movie.tvdbURL(); got != "https://thetvdb.com/dereferrer/movie/290" {
|
||||
t.Fatalf("unexpected movie tvdb URL %q", got)
|
||||
}
|
||||
const severanceTMDBURL = "https://www.themoviedb.org/tv/95396"
|
||||
series := providerIDs{MediaType: "series", TMDB: "95396", TVDB: "371980"}
|
||||
if got := series.tmdbURL(); got != severanceTMDBURL {
|
||||
t.Fatalf("unexpected series tmdb URL %q", got)
|
||||
}
|
||||
if got := series.tvdbURL(); got != "https://thetvdb.com/dereferrer/series/371980" {
|
||||
t.Fatalf("unexpected series tvdb URL %q", got)
|
||||
}
|
||||
if got := series.titleURL(); got != series.tmdbURL() {
|
||||
t.Fatalf("title URL must prefer TMDB, got %q", got)
|
||||
}
|
||||
imdbOnly := providerIDs{IMDB: "tt1160419"}
|
||||
if got := imdbOnly.titleURL(); got != "https://www.imdb.com/title/tt1160419/" {
|
||||
t.Fatalf("title URL must fall back to IMDb, got %q", got)
|
||||
}
|
||||
if (providerIDs{}).titleURL() != "" || (providerIDs{}).linkLine() != "" {
|
||||
t.Fatal("empty IDs must render no URLs")
|
||||
}
|
||||
if got := movie.linkLine(); got != "[TMDB](https://www.themoviedb.org/movie/438631) • "+
|
||||
"[IMDb](https://www.imdb.com/title/tt1160419/) • [TVDB](https://thetvdb.com/dereferrer/movie/290)" {
|
||||
t.Fatalf("unexpected link line %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverviewSnippet(t *testing.T) {
|
||||
if got := overviewSnippet(" short overview "); got != "short overview" {
|
||||
t.Fatalf("short overviews must pass through trimmed, got %q", got)
|
||||
}
|
||||
long := strings.Repeat("word ", 200)
|
||||
got := overviewSnippet(long)
|
||||
if len(got) > discordOverviewLimit {
|
||||
t.Fatalf("snippet too long: %d bytes", len(got))
|
||||
}
|
||||
// Every word in the input is "word": a word-boundary clip must keep the
|
||||
// last word whole.
|
||||
if !strings.HasSuffix(got, "word…") {
|
||||
t.Fatalf("snippet should clip on a word boundary with an ellipsis, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatingAndGenresLabels(t *testing.T) {
|
||||
const imdbWin = "★ 8.7 IMDb"
|
||||
if got := ratingLabel(8.7, 8.1); got != imdbWin {
|
||||
t.Fatalf("IMDb rating must win, got %q", got)
|
||||
}
|
||||
if got := ratingLabel(0, 8.1); got != "★ 8.1 TMDB" {
|
||||
t.Fatalf("TMDB fallback wrong, got %q", got)
|
||||
}
|
||||
if got := ratingLabel(0, 0); got != "" {
|
||||
t.Fatalf("unknown ratings must render nothing, got %q", got)
|
||||
}
|
||||
if got := genresLabel([]string{"Drama", "", "Sci-Fi & Fantasy", "Mystery", "Thriller"}); got != "Drama, Sci-Fi & Fantasy, Mystery" {
|
||||
t.Fatalf("genres must cap at three non-empty entries, got %q", got)
|
||||
}
|
||||
if got := genresLabel(nil); got != "" {
|
||||
t.Fatalf("no genres must render nothing, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DiscordPrefs is one account's Discord DM notification state as the API
|
||||
// surfaces it: the OAuth-linked identity, the user-chosen mode, and the last
|
||||
// delivery failure (for link health in the settings UI).
|
||||
type DiscordPrefs struct {
|
||||
UserID int
|
||||
DiscordUserID string
|
||||
DiscordUsername string
|
||||
Mode string
|
||||
LinkFailure string
|
||||
}
|
||||
|
||||
// Linked reports whether the account has completed the Discord OAuth link.
|
||||
func (p DiscordPrefs) Linked() bool { return p.DiscordUserID != "" }
|
||||
|
||||
// DiscordPrefsRepository owns notification_discord_prefs and the one-time
|
||||
// link-state rows for the OAuth flow.
|
||||
type DiscordPrefsRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewDiscordPrefsRepository creates a DiscordPrefsRepository.
|
||||
func NewDiscordPrefsRepository(pool *pgxpool.Pool) *DiscordPrefsRepository {
|
||||
return &DiscordPrefsRepository{pool: pool}
|
||||
}
|
||||
|
||||
// Get returns the account's Discord prefs; missing rows default to mode off
|
||||
// and no linked identity.
|
||||
func (r *DiscordPrefsRepository) Get(ctx context.Context, userID int) (DiscordPrefs, error) {
|
||||
prefs := DiscordPrefs{UserID: userID, Mode: ChannelModeOff}
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT discord_user_id, discord_username, mode, link_failure
|
||||
FROM notification_discord_prefs WHERE user_id = $1`,
|
||||
userID,
|
||||
).Scan(&prefs.DiscordUserID, &prefs.DiscordUsername, &prefs.Mode, &prefs.LinkFailure)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return prefs, nil
|
||||
}
|
||||
if err != nil {
|
||||
return prefs, fmt.Errorf("get discord prefs: %w", err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// SetIdentity stores the OAuth-linked Discord identity. The watermark resets
|
||||
// to now so the backlog never floods a fresh link, the DM channel cache is
|
||||
// cleared (the identity may have changed), and failure state is wiped so the
|
||||
// first send happens promptly. An existing mode survives a re-link.
|
||||
func (r *DiscordPrefsRepository) SetIdentity(ctx context.Context, userID int, discordUserID, discordUsername string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO notification_discord_prefs (user_id, discord_user_id, discord_username)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
discord_user_id = EXCLUDED.discord_user_id,
|
||||
discord_username = EXCLUDED.discord_username,
|
||||
dm_channel_id = '',
|
||||
watermark_created_at = now(),
|
||||
watermark_id = '',
|
||||
last_attempt_at = NULL,
|
||||
consecutive_failures = 0,
|
||||
link_failure = '',
|
||||
updated_at = now()`,
|
||||
userID, discordUserID, discordUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set discord identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearIdentity unlinks the Discord account and switches the channel off.
|
||||
func (r *DiscordPrefsRepository) ClearIdentity(ctx context.Context, userID int) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE notification_discord_prefs SET
|
||||
discord_user_id = '',
|
||||
discord_username = '',
|
||||
dm_channel_id = '',
|
||||
mode = 'off',
|
||||
last_attempt_at = NULL,
|
||||
consecutive_failures = 0,
|
||||
link_failure = '',
|
||||
updated_at = now()
|
||||
WHERE user_id = $1`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear discord identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMode updates the account's Discord mode. Enabling from off resets the
|
||||
// watermark to now so the backlog never floods a fresh opt-in, and clears
|
||||
// failure backoff so the first send happens promptly. Returns
|
||||
// ErrDiscordNotLinked when no linked identity exists.
|
||||
func (r *DiscordPrefsRepository) SetMode(ctx context.Context, userID int, mode string) error {
|
||||
if !ValidChannelMode(mode) {
|
||||
return fmt.Errorf("invalid discord mode %q", mode)
|
||||
}
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE notification_discord_prefs SET
|
||||
mode = $2,
|
||||
watermark_created_at = CASE
|
||||
WHEN notification_discord_prefs.mode = 'off' THEN now()
|
||||
ELSE notification_discord_prefs.watermark_created_at
|
||||
END,
|
||||
watermark_id = CASE
|
||||
WHEN notification_discord_prefs.mode = 'off' THEN ''
|
||||
ELSE notification_discord_prefs.watermark_id
|
||||
END,
|
||||
last_attempt_at = NULL,
|
||||
consecutive_failures = 0,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1 AND discord_user_id <> ''`,
|
||||
userID, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set discord mode: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
// Switching off without a linked row is already the desired state
|
||||
// (e.g. a mode update racing an unlink); only enabling needs a link.
|
||||
if mode == ChannelModeOff {
|
||||
return nil
|
||||
}
|
||||
return ErrDiscordNotLinked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListActiveRecipients returns every linked account with Discord DMs on.
|
||||
// Disabled or deleted accounts drop out of the join.
|
||||
func (r *DiscordPrefsRepository) ListActiveRecipients(ctx context.Context) ([]accountRecipient[int], error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT p.user_id, p.mode, p.watermark_created_at, p.watermark_id,
|
||||
p.last_digest_at, p.last_attempt_at, p.consecutive_failures
|
||||
FROM notification_discord_prefs p
|
||||
JOIN users u ON u.id = p.user_id AND u.enabled
|
||||
WHERE p.mode <> 'off' AND p.discord_user_id <> ''
|
||||
ORDER BY p.user_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list discord recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]accountRecipient[int], 0, 8)
|
||||
for rows.Next() {
|
||||
var rec accountRecipient[int]
|
||||
if err := rows.Scan(&rec.Key, &rec.Mode, &rec.WatermarkCreatedAt, &rec.WatermarkID,
|
||||
&rec.LastDigestAt, &rec.LastAttemptAt, &rec.ConsecutiveFailures); err != nil {
|
||||
return nil, fmt.Errorf("scan discord recipient: %w", err)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// claimForUpdate locks the account's prefs row for one dispatch attempt.
|
||||
// SKIP LOCKED makes concurrent nodes pass over each other's in-flight users
|
||||
// instead of double-sending; (nil, nil) means another node holds the row.
|
||||
func (r *DiscordPrefsRepository) claimForUpdate(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient[int], error) {
|
||||
rec := accountRecipient[int]{Key: userID}
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT mode, watermark_created_at, watermark_id, last_digest_at,
|
||||
last_attempt_at, consecutive_failures
|
||||
FROM notification_discord_prefs
|
||||
WHERE user_id = $1 AND discord_user_id <> ''
|
||||
FOR UPDATE SKIP LOCKED`,
|
||||
userID,
|
||||
).Scan(&rec.Mode, &rec.WatermarkCreatedAt, &rec.WatermarkID,
|
||||
&rec.LastDigestAt, &rec.LastAttemptAt, &rec.ConsecutiveFailures)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim discord prefs: %w", err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// identityForSend reads the locked row's identity fields inside the claim
|
||||
// transaction, so the send targets exactly the identity the lock covers.
|
||||
func (r *DiscordPrefsRepository) identityForSend(ctx context.Context, tx pgx.Tx, userID int) (discordUserID, dmChannelID string, err error) {
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT discord_user_id, dm_channel_id
|
||||
FROM notification_discord_prefs WHERE user_id = $1`,
|
||||
userID,
|
||||
).Scan(&discordUserID, &dmChannelID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read discord identity: %w", err)
|
||||
}
|
||||
return discordUserID, dmChannelID, nil
|
||||
}
|
||||
|
||||
// cacheDMChannel stores a freshly opened DM channel ID under the claim lock.
|
||||
func (r *DiscordPrefsRepository) cacheDMChannel(ctx context.Context, tx pgx.Tx, userID int, channelID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE notification_discord_prefs SET dm_channel_id = $2, updated_at = now()
|
||||
WHERE user_id = $1`,
|
||||
userID, channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cache dm channel: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markSent advances the watermark past everything the DM covered and resets
|
||||
// failure and link-health state. digestAt is non-nil for digest sends.
|
||||
func (r *DiscordPrefsRepository) markSent(ctx context.Context, tx pgx.Tx, userID int, watermark Cursor, digestAt *time.Time) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE notification_discord_prefs SET
|
||||
watermark_created_at = $2,
|
||||
watermark_id = $3,
|
||||
last_digest_at = COALESCE($4, last_digest_at),
|
||||
last_attempt_at = now(),
|
||||
consecutive_failures = 0,
|
||||
link_failure = '',
|
||||
updated_at = now()
|
||||
WHERE user_id = $1`,
|
||||
userID, watermark.CreatedAt, watermark.ID, digestAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark discord sent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markFailure records a failed send for backoff and surfaces the failure in
|
||||
// the settings UI; the watermark stays put so the next eligible pass retries
|
||||
// the same items.
|
||||
func (r *DiscordPrefsRepository) markFailure(ctx context.Context, tx pgx.Tx, userID int, message string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE notification_discord_prefs SET
|
||||
last_attempt_at = now(),
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
link_failure = $2,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1`,
|
||||
userID, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark discord failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateLinkState records a one-time OAuth state row for the link flow.
|
||||
func (r *DiscordPrefsRepository) CreateLinkState(ctx context.Context, state string, userID int, expiresAt time.Time) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO notification_discord_link_state (state, user_id, expires_at)
|
||||
VALUES ($1, $2, $3)`,
|
||||
state, userID, expiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create discord link state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConsumeLinkState atomically deletes and returns an unexpired link-state
|
||||
// row, making each state single-use. ok is false for unknown, already-used,
|
||||
// or expired states.
|
||||
func (r *DiscordPrefsRepository) ConsumeLinkState(ctx context.Context, state string) (userID int, ok bool, err error) {
|
||||
err = r.pool.QueryRow(ctx, `
|
||||
DELETE FROM notification_discord_link_state
|
||||
WHERE state = $1 AND expires_at > now()
|
||||
RETURNING user_id`,
|
||||
state,
|
||||
).Scan(&userID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("consume discord link state: %w", err)
|
||||
}
|
||||
return userID, true, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredLinkStates reaps abandoned link-flow rows (retention).
|
||||
func (r *DiscordPrefsRepository) DeleteExpiredLinkStates(ctx context.Context) (int64, error) {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM notification_discord_link_state WHERE expires_at <= now()`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired discord link states: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
netmail "net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/mail"
|
||||
)
|
||||
|
||||
// emailVerifyTTL bounds how long a verification link stays usable.
|
||||
const emailVerifyTTL = 24 * time.Hour
|
||||
|
||||
// Errors surfaced by the custom-address flow for the API layer.
|
||||
var (
|
||||
ErrEmailInvalidAddress = errors.New("invalid email address")
|
||||
ErrEmailChildProfile = errors.New("child profiles cannot set a custom notification address")
|
||||
ErrEmailNoLinkBase = errors.New("no external URL is configured for verification links")
|
||||
)
|
||||
|
||||
// newEmailToken mints a single-use capability token and its SHA-256 hex
|
||||
// digest for at-rest storage.
|
||||
func newEmailToken() (token, tokenHash string, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", "", fmt.Errorf("generate email token: %w", err)
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(raw)
|
||||
return token, hashEmailToken(token), nil
|
||||
}
|
||||
|
||||
// hashEmailToken returns the at-rest digest of a verification token.
|
||||
func hashEmailToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// emailLinkBase is the externally reachable base URL for tokenized email
|
||||
// links: the admin's notifications.email.external_url, falling back to the
|
||||
// server's public URL.
|
||||
func (s *System) emailLinkBase(ctx context.Context) string {
|
||||
if base := s.Settings.EmailExternalURL(ctx); base != "" {
|
||||
return base
|
||||
}
|
||||
return s.publicURL
|
||||
}
|
||||
|
||||
// SetPublicURL wires the server's externally reachable base URL, used as the
|
||||
// fallback for verification links when notifications.email.external_url is
|
||||
// unset. Optional.
|
||||
func (s *System) SetPublicURL(url string) {
|
||||
if s != nil {
|
||||
s.publicURL = strings.TrimRight(url, "/")
|
||||
}
|
||||
}
|
||||
|
||||
// RequestEmailAddress starts custom-address verification for the profile: it
|
||||
// validates and stores the pending address, then emails it a single-use
|
||||
// confirmation link. Notifications keep flowing to the previous destination
|
||||
// until the new address is verified. Child profiles are refused — a session
|
||||
// acting as a child profile must not be able to route the household's
|
||||
// viewing activity to an arbitrary address.
|
||||
func (s *System) RequestEmailAddress(ctx context.Context, userID int, profileID, address string) error {
|
||||
if s == nil || s.EmailPrefs == nil {
|
||||
return ErrEmailInvalidAddress
|
||||
}
|
||||
parsed, err := netmail.ParseAddress(strings.TrimSpace(address))
|
||||
if err != nil || parsed.Address != strings.TrimSpace(address) {
|
||||
return ErrEmailInvalidAddress
|
||||
}
|
||||
address = parsed.Address
|
||||
profile := s.lookupProfile(ctx, userID, profileID)
|
||||
if profile == nil || profile.IsChild {
|
||||
return ErrEmailChildProfile
|
||||
}
|
||||
linkBase := s.emailLinkBase(ctx)
|
||||
if linkBase == "" {
|
||||
return ErrEmailNoLinkBase
|
||||
}
|
||||
|
||||
token, tokenHash, err := newEmailToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiresAt := time.Now().Add(emailVerifyTTL)
|
||||
if err := s.EmailPrefs.RequestPendingAddress(ctx, userID, profileID, address,
|
||||
tokenHash, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verifyURL := linkBase + "/api/v1/notifications/email/verify?token=" + token
|
||||
content := composeVerificationEmail(profile.Name, verifyURL)
|
||||
err = s.mailSender.Send(ctx, mail.Message{
|
||||
To: []string{address},
|
||||
Subject: content.Subject,
|
||||
TextBody: content.Text,
|
||||
HTMLBody: content.HTML,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("send verification email: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearEmailAddress removes the profile's verified address (and any
|
||||
// in-flight verification), which also switches the channel off — there is no
|
||||
// fallback destination. Restricted to non-child profiles like setting one,
|
||||
// so a child session cannot drop a parent-configured destination.
|
||||
func (s *System) ClearEmailAddress(ctx context.Context, userID int, profileID string) error {
|
||||
if s == nil || s.EmailPrefs == nil {
|
||||
return nil
|
||||
}
|
||||
if s.profileIsChild(ctx, userID, profileID) {
|
||||
return ErrEmailChildProfile
|
||||
}
|
||||
return s.EmailPrefs.ClearCustomAddress(ctx, profileID)
|
||||
}
|
||||
|
||||
// VerifyEmailToken consumes a verification token from a clicked link,
|
||||
// promoting that profile's pending address to the verified destination.
|
||||
func (s *System) VerifyEmailToken(ctx context.Context, token string) (EmailVerifyOutcome, error) {
|
||||
if s == nil || s.EmailPrefs == nil || token == "" {
|
||||
return EmailVerifyInvalid, nil
|
||||
}
|
||||
return s.EmailPrefs.ConsumeVerifyToken(ctx, hashEmailToken(token))
|
||||
}
|
||||
|
||||
// UnsubscribeEmail handles a tokenized unsubscribe link: the matching
|
||||
// profile's email mode switches off.
|
||||
func (s *System) UnsubscribeEmail(ctx context.Context, token string) (ok bool, err error) {
|
||||
if s == nil || s.EmailPrefs == nil || token == "" {
|
||||
return false, nil
|
||||
}
|
||||
return s.EmailPrefs.UnsubscribeByToken(ctx, token)
|
||||
}
|
||||
|
||||
// composeVerificationEmail renders the address-confirmation message.
|
||||
func composeVerificationEmail(profileName, verifyURL string) emailContent {
|
||||
who := "your profile"
|
||||
if profileName != "" {
|
||||
who = "the profile “" + profileName + "”"
|
||||
}
|
||||
expiry := "The link expires in 24 hours. If you didn't request this, ignore this email — " +
|
||||
"nothing will be sent to this address."
|
||||
text := fmt.Sprintf(
|
||||
"This address was entered as the notification destination for %s on a Silo server.\n\n"+
|
||||
"To confirm and start receiving notifications here, open this link:\n\n %s\n\n"+
|
||||
"%s\n", who, verifyURL, expiry)
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString(mail.EmailParagraph(fmt.Sprintf(
|
||||
"This address was entered as the notification destination for %s on a Silo server.", who)))
|
||||
body.WriteString(mail.EmailParagraph("To confirm and start receiving notifications here:"))
|
||||
body.WriteString(mail.EmailButton("Confirm this address", verifyURL))
|
||||
body.WriteString(fmt.Sprintf(
|
||||
`<p style="margin:20px 0 0;font:400 12px/1.7 %s;color:%s;">Or paste this link into your browser:<br>`+
|
||||
`<span style="font:400 12px/1.7 %s;word-break:break-all;">%s</span></p>`,
|
||||
mail.EmailFont, mail.EmailColorMuted, mail.EmailFontMono, html.EscapeString(verifyURL)))
|
||||
|
||||
return emailContent{
|
||||
Subject: "Confirm your Silo notification address",
|
||||
Text: text,
|
||||
HTML: mail.RenderLayout(mail.LayoutOptions{
|
||||
Preheader: "Confirm this address to start receiving Silo notifications.",
|
||||
Title: "Confirm your notification address",
|
||||
BodyHTML: body.String(),
|
||||
FooterHTML: html.EscapeString(expiry),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/mail"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// emailComposeOptions carries the per-send rendering context.
|
||||
type emailComposeOptions struct {
|
||||
// BaseURL is the admin-configured external URL; empty renders without
|
||||
// links.
|
||||
BaseURL string
|
||||
// ProfileName labels whose notifications these are — several profiles on
|
||||
// one account may deliver to the same fallback address.
|
||||
ProfileName string
|
||||
// UnsubscribeURL is the tokenized one-click unsubscribe link; empty
|
||||
// renders without one.
|
||||
UnsubscribeURL string
|
||||
}
|
||||
|
||||
// composeNotificationEmail renders one email (text + HTML) for the given
|
||||
// delivery rows.
|
||||
func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailComposeOptions) emailContent {
|
||||
baseURL := opts.BaseURL
|
||||
items := collateEmailItems(rows)
|
||||
|
||||
var text strings.Builder
|
||||
var body strings.Builder
|
||||
rendered := 0
|
||||
total := items.episodes + len(items.requests) + len(items.others)
|
||||
|
||||
// writeItem renders one row: plain feeds the text body; code (optional
|
||||
// "S02E03" badge) and label feed the HTML row.
|
||||
writeItem := func(plain, code, label, href string) {
|
||||
rendered++
|
||||
if rendered > emailMaxItemsRendered {
|
||||
return
|
||||
}
|
||||
text.WriteString(" " + plain + "\n")
|
||||
var inner strings.Builder
|
||||
if code != "" {
|
||||
inner.WriteString(fmt.Sprintf(`<span style="font:500 12px/1 %s;color:%s;">%s</span>`,
|
||||
mail.EmailFontMono, mail.EmailColorMuted, html.EscapeString(code)))
|
||||
if label != "" {
|
||||
inner.WriteString(" ")
|
||||
}
|
||||
}
|
||||
inner.WriteString(html.EscapeString(label))
|
||||
content := inner.String()
|
||||
if href != "" {
|
||||
content = fmt.Sprintf(`<a href="%s" style="color:%s;text-decoration:none;">%s</a>`,
|
||||
html.EscapeString(href), mail.EmailColorText, content)
|
||||
}
|
||||
body.WriteString(fmt.Sprintf(
|
||||
`<li style="margin:0;padding:8px 2px;border-top:1px solid %s;font:400 14px/1.5 %s;color:%s;">%s</li>`,
|
||||
mail.EmailColorRule, mail.EmailFont, mail.EmailColorText, content))
|
||||
}
|
||||
writeHeading := func(title, href string) {
|
||||
text.WriteString(title + "\n")
|
||||
label := html.EscapeString(title)
|
||||
if href != "" {
|
||||
label = fmt.Sprintf(`<a href="%s" style="color:inherit;text-decoration:none;">%s</a>`,
|
||||
html.EscapeString(href), label)
|
||||
}
|
||||
top := "22px"
|
||||
if body.Len() == 0 {
|
||||
top = "0"
|
||||
}
|
||||
body.WriteString(fmt.Sprintf(
|
||||
`<h2 style="margin:%s 0 6px;font:600 15px/1.4 %s;color:%s;">%s</h2>`,
|
||||
top, mail.EmailFont, mail.EmailColorText, label))
|
||||
}
|
||||
openList := func() { body.WriteString(`<ul style="margin:0;padding:0;list-style:none;">`) }
|
||||
closeList := func() { body.WriteString(`</ul>`) }
|
||||
|
||||
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
|
||||
}
|
||||
code := episodeCode(row)
|
||||
label := row.EpisodeTitle
|
||||
if code == "" && label == "" {
|
||||
label = genericEpisodeTitle
|
||||
}
|
||||
writeItem(episodeLine(row), code, label, 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
|
||||
}
|
||||
writeItem(requestLine(row), "", requestLine(row), itemURL(baseURL, seriesID))
|
||||
}
|
||||
closeList()
|
||||
}
|
||||
if len(items.others) > 0 && rendered < emailMaxItemsRendered {
|
||||
writeHeading("Other updates", "")
|
||||
openList()
|
||||
for _, row := range items.others {
|
||||
writeItem(otherLine(row), "", 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(`<p style="margin:14px 0 0;font:400 13px/1.5 %s;color:%s;">%s</p>`,
|
||||
mail.EmailFont, mail.EmailColorMuted, html.EscapeString(more)))
|
||||
}
|
||||
|
||||
forProfile := ""
|
||||
if opts.ProfileName != "" {
|
||||
forProfile = " for " + opts.ProfileName
|
||||
}
|
||||
intro := fmt.Sprintf("New in your library%s:", forProfile)
|
||||
if mode == EmailModeDailyDigest {
|
||||
intro = fmt.Sprintf("Here's what's new%s since the last digest:", forProfile)
|
||||
}
|
||||
subjectFor := ""
|
||||
if opts.ProfileName != "" {
|
||||
subjectFor = " (for " + opts.ProfileName + ")"
|
||||
}
|
||||
|
||||
profileLabel := "this profile"
|
||||
if opts.ProfileName != "" {
|
||||
profileLabel = "the profile “" + opts.ProfileName + "”"
|
||||
}
|
||||
footer := fmt.Sprintf("You're receiving this because email notifications are enabled for"+
|
||||
" %s on your Silo account. Manage them in Settings → Notifications.", profileLabel)
|
||||
footerHTML := html.EscapeString(footer)
|
||||
if baseURL != "" {
|
||||
settingsURL := html.EscapeString(baseURL + "/settings/notifications")
|
||||
footerHTML = strings.Replace(footerHTML,
|
||||
"Settings → Notifications",
|
||||
fmt.Sprintf(`<a href="%s" style="color:%s;">Settings → Notifications</a>`,
|
||||
settingsURL, mail.EmailColorMuted), 1)
|
||||
}
|
||||
if opts.UnsubscribeURL != "" {
|
||||
footer += " To stop these emails, open: " + opts.UnsubscribeURL
|
||||
footerHTML += fmt.Sprintf(` <a href="%s" style="color:%s;">Unsubscribe</a>`,
|
||||
html.EscapeString(opts.UnsubscribeURL), mail.EmailColorMuted)
|
||||
}
|
||||
|
||||
htmlBody := mail.RenderLayout(mail.LayoutOptions{
|
||||
Preheader: emailPreheader(items),
|
||||
Title: strings.TrimSuffix(intro, ":"),
|
||||
BodyHTML: body.String(),
|
||||
FooterHTML: footerHTML,
|
||||
})
|
||||
|
||||
return emailContent{
|
||||
Subject: emailSubject(mode, items) + subjectFor,
|
||||
Text: intro + "\n\n" + text.String() + "\n" + footer + "\n",
|
||||
HTML: htmlBody,
|
||||
}
|
||||
}
|
||||
|
||||
// emailPreheader picks the inbox-preview snippet: the first item, the same
|
||||
// way a notification banner would lead with it.
|
||||
func emailPreheader(items emailItems) string {
|
||||
if len(items.series) > 0 && len(items.series[0].episodes) > 0 {
|
||||
line := episodeLine(items.series[0].episodes[0])
|
||||
if title := items.series[0].title; title != untitledSeriesGroup {
|
||||
return title + " · " + line
|
||||
}
|
||||
return line
|
||||
}
|
||||
if len(items.requests) > 0 {
|
||||
return requestLine(items.requests[0])
|
||||
}
|
||||
if len(items.others) > 0 {
|
||||
return otherLine(items.others[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/mail"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// emailChannel implements accountChannel over the shared SMTP core, keyed by
|
||||
// profile ID. The engine owns the sweep loop and watermark; this adapter only
|
||||
// knows how to list/claim email prefs rows and compose+send one profile's
|
||||
// message to its resolved destination (verified custom address, else the
|
||||
// account email).
|
||||
type emailChannel struct {
|
||||
prefs *EmailPrefsRepository
|
||||
deliveries *DeliveryRepository
|
||||
settings *Settings
|
||||
sender mail.Sender
|
||||
// profileName resolves a display name for the email copy; best-effort
|
||||
// (empty on any failure). Set by NewSystem after construction.
|
||||
profileName func(ctx context.Context, userID int, profileID string) string
|
||||
}
|
||||
|
||||
// The assertion also keeps staticcheck's unused-analysis aware that the
|
||||
// adapter methods are consumed through the generic engine interface.
|
||||
var _ accountChannel[string] = (*emailChannel)(nil)
|
||||
|
||||
func (c *emailChannel) name() string { return "email" }
|
||||
|
||||
func (c *emailChannel) enabled(ctx context.Context) bool {
|
||||
return c.settings.EmailEnabled(ctx) && c.sender.Enabled(ctx)
|
||||
}
|
||||
|
||||
func (c *emailChannel) allowPerEpisode(ctx context.Context) bool {
|
||||
return c.settings.EmailAllowPerEpisode(ctx)
|
||||
}
|
||||
|
||||
func (c *emailChannel) digestHour(ctx context.Context) int {
|
||||
return c.settings.EmailDigestHour(ctx)
|
||||
}
|
||||
|
||||
func (c *emailChannel) listRecipients(ctx context.Context) ([]accountRecipient[string], error) {
|
||||
return c.prefs.ListActiveRecipients(ctx)
|
||||
}
|
||||
|
||||
func (c *emailChannel) hasPendingSince(ctx context.Context, profileID string, since Cursor) (bool, error) {
|
||||
return c.deliveries.HasForProfileSince(ctx, profileID, since)
|
||||
}
|
||||
|
||||
func (c *emailChannel) listSince(ctx context.Context, tx pgx.Tx, profileID string, since Cursor, until time.Time, limit int) ([]DeliveryRow, error) {
|
||||
return c.deliveries.ListForProfileSince(ctx, tx, profileID, since, until, limit)
|
||||
}
|
||||
|
||||
func (c *emailChannel) claim(ctx context.Context, tx pgx.Tx, profileID string) (*accountRecipient[string], error) {
|
||||
return c.prefs.claimForUpdate(ctx, tx, profileID)
|
||||
}
|
||||
|
||||
func (c *emailChannel) markSent(ctx context.Context, tx pgx.Tx, profileID string, watermark Cursor, digestAt *time.Time) error {
|
||||
return c.prefs.markSent(ctx, tx, profileID, watermark, digestAt)
|
||||
}
|
||||
|
||||
func (c *emailChannel) markFailure(ctx context.Context, tx pgx.Tx, profileID string, _ error) error {
|
||||
return c.prefs.markFailure(ctx, tx, profileID)
|
||||
}
|
||||
|
||||
// send composes and sends one profile's pending notifications. The
|
||||
// destination is re-read under the claim so a mid-pass address removal fails
|
||||
// cleanly instead of sending to a stale recipient.
|
||||
func (c *emailChannel) send(ctx context.Context, tx pgx.Tx, profileID string, mode string, rows []DeliveryRow) error {
|
||||
email, userID, unsubscribeToken, err := c.prefs.destinationForSend(ctx, tx, profileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if email == "" {
|
||||
return fmt.Errorf("profile %s has no usable email address", profileID)
|
||||
}
|
||||
// The token is minted lazily, under the claim lock, right before the
|
||||
// first email that embeds it — this is the only mint point.
|
||||
if unsubscribeToken == "" {
|
||||
unsubscribeToken, _, err = newEmailToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.prefs.setUnsubscribeToken(ctx, tx, profileID, unsubscribeToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := c.settings.EmailExternalURL(ctx)
|
||||
opts := emailComposeOptions{
|
||||
BaseURL: baseURL,
|
||||
UnsubscribeURL: emailUnsubscribeURL(baseURL, unsubscribeToken),
|
||||
}
|
||||
if c.profileName != nil {
|
||||
opts.ProfileName = c.profileName(ctx, userID, profileID)
|
||||
}
|
||||
|
||||
content := composeNotificationEmail(mode, rows, opts)
|
||||
msg := mail.Message{
|
||||
To: []string{email},
|
||||
Subject: content.Subject,
|
||||
TextBody: content.Text,
|
||||
HTMLBody: content.HTML,
|
||||
}
|
||||
if opts.UnsubscribeURL != "" {
|
||||
// RFC 8058 one-click unsubscribe; the POST target is the same URL.
|
||||
msg.Headers = map[string]string{
|
||||
"List-Unsubscribe": "<" + opts.UnsubscribeURL + ">",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
}
|
||||
err = c.sender.Send(ctx, msg)
|
||||
if errors.Is(err, mail.ErrNotConfigured) {
|
||||
return fmt.Errorf("smtp not configured: %w", errChannelUnavailable)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// emailUnsubscribeURL builds the tokenized unsubscribe link; empty when no
|
||||
// external URL is configured (the email then renders without one).
|
||||
func emailUnsubscribeURL(baseURL, token string) string {
|
||||
if baseURL == "" || token == "" {
|
||||
return ""
|
||||
}
|
||||
return baseURL + "/api/v1/notifications/email/unsubscribe?token=" + token
|
||||
}
|
||||
|
||||
// Errors surfaced by the email preference 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("profile has no verified 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)
|
||||
}
|
||||
|
||||
// EmailPreferencesState is one profile's email channel state as the API
|
||||
// surfaces it.
|
||||
type EmailPreferencesState struct {
|
||||
Mode string
|
||||
CustomEmail string
|
||||
PendingEmail string
|
||||
IsChild bool
|
||||
}
|
||||
|
||||
// EmailPreferences returns the profile's email notification state.
|
||||
func (s *System) EmailPreferences(ctx context.Context, userID int, profileID string) (EmailPreferencesState, error) {
|
||||
state := EmailPreferencesState{Mode: EmailModeOff}
|
||||
if s == nil || s.EmailPrefs == nil {
|
||||
return state, nil
|
||||
}
|
||||
prefs, err := s.EmailPrefs.Get(ctx, profileID)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.Mode = prefs.Mode
|
||||
state.CustomEmail = prefs.CustomEmail
|
||||
state.PendingEmail = prefs.PendingEmail
|
||||
state.IsChild = s.profileIsChild(ctx, userID, profileID)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// SetEmailMode validates and stores the profile's email mode. Enabling
|
||||
// requires the profile's own verified address and, for per-episode, the
|
||||
// admin allowance.
|
||||
func (s *System) SetEmailMode(ctx context.Context, userID int, profileID, mode string) error {
|
||||
if s == nil || s.EmailPrefs == nil {
|
||||
return ErrEmailModeInvalid
|
||||
}
|
||||
if !ValidChannelMode(mode) {
|
||||
return ErrEmailModeInvalid
|
||||
}
|
||||
if ModeIncludesPerEpisode(mode) && !s.Settings.EmailAllowPerEpisode(ctx) {
|
||||
return ErrEmailModeNotAllowed
|
||||
}
|
||||
if mode != EmailModeOff {
|
||||
prefs, err := s.EmailPrefs.Get(ctx, profileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prefs.CustomEmail == "" {
|
||||
return ErrEmailNoAddress
|
||||
}
|
||||
}
|
||||
return s.EmailPrefs.SetMode(ctx, userID, profileID, mode)
|
||||
}
|
||||
|
||||
// lookupProfile loads the profile from its account's userstore; nil on any
|
||||
// failure (callers treat that as the safe default).
|
||||
func (s *System) lookupProfile(ctx context.Context, userID int, profileID string) *userstore.Profile {
|
||||
if s == nil || s.stores == nil {
|
||||
return nil
|
||||
}
|
||||
store, err := s.stores.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// profileIsChild reports whether the profile is a child profile. Best-effort:
|
||||
// lookup failures err on the safe side (treated as child, which only
|
||||
// restricts custom-address edits).
|
||||
func (s *System) profileIsChild(ctx context.Context, userID int, profileID string) bool {
|
||||
profile := s.lookupProfile(ctx, userID, profileID)
|
||||
return profile == nil || profile.IsChild
|
||||
}
|
||||
|
||||
// lookupProfileName resolves the profile's display name for email copy;
|
||||
// best-effort, empty on any failure.
|
||||
func (s *System) lookupProfileName(ctx context.Context, userID int, profileID string) string {
|
||||
if profile := s.lookupProfile(ctx, userID, profileID); profile != nil {
|
||||
return profile.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEffectiveChannelMode(t *testing.T) {
|
||||
if got := effectiveChannelMode(ChannelModePerEpisode, true); got != ChannelModePerEpisode {
|
||||
t.Fatalf("allowed per-episode coerced to %q", got)
|
||||
}
|
||||
if got := effectiveChannelMode(ChannelModePerEpisode, false); got != ChannelModeDailyDigest {
|
||||
t.Fatalf("disallowed per-episode should coerce to digest, got %q", got)
|
||||
}
|
||||
if got := effectiveChannelMode(ChannelModeDailyDigest, false); got != ChannelModeDailyDigest {
|
||||
t.Fatalf("digest mode changed to %q", got)
|
||||
}
|
||||
if got := effectiveChannelMode(ChannelModeOff, true); got != ChannelModeOff {
|
||||
t.Fatalf("off mode changed to %q", got)
|
||||
}
|
||||
if got := effectiveChannelMode(ChannelModePerEpisodeAndDigest, true); got != ChannelModePerEpisodeAndDigest {
|
||||
t.Fatalf("allowed combined mode coerced to %q", got)
|
||||
}
|
||||
if got := effectiveChannelMode(ChannelModePerEpisodeAndDigest, false); got != ChannelModeDailyDigest {
|
||||
t.Fatalf("disallowed combined mode should coerce to digest, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDigestDue(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 channelDigestDue(morning, 8, nil) {
|
||||
t.Fatal("digest due before today's send hour")
|
||||
}
|
||||
if !channelDigestDue(afternoon, 8, nil) {
|
||||
t.Fatal("first-ever digest not due after send hour")
|
||||
}
|
||||
if !channelDigestDue(afternoon, 8, &yesterday) {
|
||||
t.Fatal("digest not due when last one was yesterday")
|
||||
}
|
||||
if channelDigestDue(afternoon, 8, &today) {
|
||||
t.Fatal("digest due twice in one day")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelRetryEligible(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 !channelRetryEligible(now, nil, 0) {
|
||||
t.Fatal("clean account not eligible")
|
||||
}
|
||||
if !channelRetryEligible(now, &recent, 0) {
|
||||
t.Fatal("successful account not eligible")
|
||||
}
|
||||
if channelRetryEligible(now, &recent, 1) {
|
||||
t.Fatal("eligible 30s after first failure (backoff is 1m)")
|
||||
}
|
||||
if !channelRetryEligible(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 !channelRetryEligible(now, &old, 60) {
|
||||
t.Fatal("not eligible past the 6h backoff cap")
|
||||
}
|
||||
if channelRetryEligible(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, emailComposeOptions{BaseURL: "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, emailComposeOptions{})
|
||||
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 = `<script>alert("x")</script>`
|
||||
content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, emailComposeOptions{})
|
||||
if strings.Contains(content.HTML, "<script>") {
|
||||
t.Fatalf("series title not escaped:\n%s", content.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeNotificationEmailProfileAndUnsubscribe(t *testing.T) {
|
||||
rows := []DeliveryRow{emailEpisodeRow("01A", "p1", "ep-1", 2, 3)}
|
||||
opts := emailComposeOptions{
|
||||
BaseURL: "https://silo.example.com",
|
||||
ProfileName: "Emma & <Kids>",
|
||||
UnsubscribeURL: "https://silo.example.com/api/v1/notifications/email/unsubscribe?token=tok",
|
||||
}
|
||||
content := composeNotificationEmail(EmailModePerEpisode, rows, opts)
|
||||
if !strings.Contains(content.Subject, "(for Emma & <Kids>)") {
|
||||
t.Fatalf("subject missing profile label: %q", content.Subject)
|
||||
}
|
||||
if strings.Contains(content.HTML, "<Kids>") {
|
||||
t.Fatalf("profile name not escaped in HTML:\n%s", content.HTML)
|
||||
}
|
||||
if !strings.Contains(content.HTML, `href="https://silo.example.com/api/v1/notifications/email/unsubscribe?token=tok"`) {
|
||||
t.Fatalf("unsubscribe link missing from HTML:\n%s", content.HTML)
|
||||
}
|
||||
if !strings.Contains(content.Text, "To stop these emails, open: "+opts.UnsubscribeURL) {
|
||||
t.Fatalf("unsubscribe link missing from text:\n%s", content.Text)
|
||||
}
|
||||
|
||||
plain := composeNotificationEmail(EmailModePerEpisode, rows, emailComposeOptions{})
|
||||
if strings.Contains(plain.Subject, "(for") || strings.Contains(plain.Text, "To stop these emails") {
|
||||
t.Fatalf("profile/unsubscribe copy leaked into unconfigured email: %q", plain.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeVerificationEmail(t *testing.T) {
|
||||
content := composeVerificationEmail(`<b>Emma</b>`, "https://silo.example.com/api/v1/notifications/email/verify?token=tok")
|
||||
if !strings.Contains(content.Text, "https://silo.example.com/api/v1/notifications/email/verify?token=tok") {
|
||||
t.Fatalf("verify link missing from text:\n%s", content.Text)
|
||||
}
|
||||
if strings.Contains(content.HTML, "<b>Emma</b>") {
|
||||
t.Fatalf("profile name not escaped:\n%s", content.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailTokens(t *testing.T) {
|
||||
token, hash, err := newEmailToken()
|
||||
if err != nil {
|
||||
t.Fatalf("newEmailToken: %v", err)
|
||||
}
|
||||
if token == "" || hash == "" || token == hash {
|
||||
t.Fatalf("degenerate token/hash: %q / %q", token, hash)
|
||||
}
|
||||
if hashEmailToken(token) != hash {
|
||||
t.Fatal("hashEmailToken does not round-trip newEmailToken")
|
||||
}
|
||||
other, _, _ := newEmailToken()
|
||||
if other == token {
|
||||
t.Fatal("tokens are not unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailUnsubscribeURL(t *testing.T) {
|
||||
if got := emailUnsubscribeURL("", "tok"); got != "" {
|
||||
t.Fatalf("URL built without a base: %q", got)
|
||||
}
|
||||
if got := emailUnsubscribeURL("https://x", ""); got != "" {
|
||||
t.Fatalf("URL built without a token: %q", got)
|
||||
}
|
||||
want := "https://x/api/v1/notifications/email/unsubscribe?token=tok"
|
||||
if got := emailUnsubscribeURL("https://x", "tok"); got != want {
|
||||
t.Fatalf("unexpected unsubscribe URL %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeNotificationEmailCapsRenderedItems(t *testing.T) {
|
||||
rows := make([]DeliveryRow, 0, emailMaxItemsRendered+10)
|
||||
for i := range emailMaxItemsRendered + 10 {
|
||||
rows = append(rows, emailEpisodeRow(
|
||||
fmt.Sprintf("01%03d", i), "p1", fmt.Sprintf("ep-%d", i), 1, i+1))
|
||||
}
|
||||
content := composeNotificationEmail(EmailModeDailyDigest, rows, emailComposeOptions{})
|
||||
if !strings.Contains(content.Text, "and 10 more in your Silo inbox") {
|
||||
t.Fatalf("overflow line missing:\n%s", content.Text)
|
||||
}
|
||||
if count := strings.Count(content.HTML, "<li"); count != emailMaxItemsRendered {
|
||||
t.Fatalf("expected %d rendered items, got %d", emailMaxItemsRendered, count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Email notification modes. The channel is profile-level: each profile owns
|
||||
// its mode, dispatch watermark, and verified destination address. There is
|
||||
// deliberately no fallback to the login account's email — without it, every
|
||||
// profile of a household would funnel mail to the account holder. A profile
|
||||
// receives nothing until its own address is verified. The values are the
|
||||
// shared account-channel modes.
|
||||
const (
|
||||
EmailModeOff = ChannelModeOff
|
||||
EmailModePerEpisode = ChannelModePerEpisode
|
||||
EmailModeDailyDigest = ChannelModeDailyDigest
|
||||
)
|
||||
|
||||
// Verification-send rate limits: a minimum gap between sends plus a daily
|
||||
// cap, so the server cannot be used to spray arbitrary addresses.
|
||||
const (
|
||||
emailVerifyMinInterval = time.Minute
|
||||
emailVerifyDailyCap = 10
|
||||
)
|
||||
|
||||
// EmailPrefs is one profile's email notification state: the chosen mode, the
|
||||
// custom-address verification state, and the worker's dispatch watermark and
|
||||
// failure backoff counters.
|
||||
type EmailPrefs struct {
|
||||
ProfileID string
|
||||
UserID int
|
||||
Mode string
|
||||
CustomEmail string
|
||||
PendingEmail string
|
||||
PendingExpiresAt *time.Time
|
||||
WatermarkCreatedAt time.Time
|
||||
WatermarkID string
|
||||
LastDigestAt *time.Time
|
||||
LastAttemptAt *time.Time
|
||||
ConsecutiveFailures int
|
||||
}
|
||||
|
||||
// EmailPrefsRepository owns notification_email_prefs.
|
||||
type EmailPrefsRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewEmailPrefsRepository creates an EmailPrefsRepository.
|
||||
func NewEmailPrefsRepository(pool *pgxpool.Pool) *EmailPrefsRepository {
|
||||
return &EmailPrefsRepository{pool: pool}
|
||||
}
|
||||
|
||||
// Get returns the profile's email prefs; missing rows default to mode off.
|
||||
func (r *EmailPrefsRepository) Get(ctx context.Context, profileID string) (EmailPrefs, error) {
|
||||
prefs := EmailPrefs{ProfileID: profileID, Mode: EmailModeOff}
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT user_id, mode, custom_email, pending_email, pending_expires_at,
|
||||
watermark_created_at, watermark_id, last_digest_at,
|
||||
last_attempt_at, consecutive_failures
|
||||
FROM notification_email_prefs WHERE profile_id = $1`,
|
||||
profileID,
|
||||
).Scan(&prefs.UserID, &prefs.Mode, &prefs.CustomEmail, &prefs.PendingEmail,
|
||||
&prefs.PendingExpiresAt, &prefs.WatermarkCreatedAt, &prefs.WatermarkID,
|
||||
&prefs.LastDigestAt, &prefs.LastAttemptAt, &prefs.ConsecutiveFailures)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return prefs, nil
|
||||
}
|
||||
if err != nil {
|
||||
return prefs, fmt.Errorf("get email prefs: %w", err)
|
||||
}
|
||||
// An expired pending verification is dead; don't surface it.
|
||||
if prefs.PendingExpiresAt != nil && prefs.PendingExpiresAt.Before(time.Now()) {
|
||||
prefs.PendingEmail = ""
|
||||
prefs.PendingExpiresAt = nil
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// SetMode upserts the profile's email mode. Enabling from off (or creating
|
||||
// the row) resets the watermark to now so the backlog never floods a fresh
|
||||
// opt-in, and clears failure backoff so the first send happens promptly.
|
||||
// The unsubscribe token is not minted here: the send path backfills it under
|
||||
// the claim lock right before the first email that embeds it.
|
||||
func (r *EmailPrefsRepository) SetMode(ctx context.Context, userID int, profileID, mode string) error {
|
||||
if !ValidChannelMode(mode) {
|
||||
return fmt.Errorf("invalid email mode %q", mode)
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO notification_email_prefs (profile_id, user_id, mode)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (profile_id) DO UPDATE SET
|
||||
mode = EXCLUDED.mode,
|
||||
user_id = EXCLUDED.user_id,
|
||||
watermark_created_at = CASE
|
||||
WHEN notification_email_prefs.mode = 'off' THEN now()
|
||||
ELSE notification_email_prefs.watermark_created_at
|
||||
END,
|
||||
watermark_id = CASE
|
||||
WHEN notification_email_prefs.mode = 'off' THEN ''
|
||||
ELSE notification_email_prefs.watermark_id
|
||||
END,
|
||||
last_attempt_at = NULL,
|
||||
consecutive_failures = 0,
|
||||
updated_at = now()`,
|
||||
profileID, userID, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set email mode: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Errors surfaced by the custom-address verification flow.
|
||||
var (
|
||||
ErrEmailVerifyRateLimited = errors.New("verification emails are rate limited; try again later")
|
||||
ErrEmailAddressInUse = errors.New("email address is already in use")
|
||||
)
|
||||
|
||||
// addressInUse reports whether the address is already claimed: verified for
|
||||
// another profile, or identifying another login account (its email, or a
|
||||
// username that is an email address). The requesting profile and its own
|
||||
// account are excluded — pointing a profile at its own account email is the
|
||||
// expected common case.
|
||||
func addressInUse(ctx context.Context, q querier, address, profileID string, userID int) (bool, error) {
|
||||
var inUse bool
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_email_prefs
|
||||
WHERE lower(custom_email) = lower($1) AND profile_id <> $2
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM users
|
||||
WHERE id <> $3
|
||||
AND (lower(COALESCE(email, '')) = lower($1) OR lower(username) = lower($1))
|
||||
)`,
|
||||
address, profileID, userID,
|
||||
).Scan(&inUse)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check address in use: %w", err)
|
||||
}
|
||||
return inUse, nil
|
||||
}
|
||||
|
||||
// querier is the subset of pgx.Tx / pgxpool.Pool the uniqueness check needs.
|
||||
type querier interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
// RequestPendingAddress stores a new pending custom address and its
|
||||
// verification token, enforcing the resend rate limits atomically. The
|
||||
// previous pending state (if any) is replaced. The daily cap's "day" is the
|
||||
// UTC day of pending_last_sent_at: the counter resets on the first request of
|
||||
// a new day.
|
||||
func (r *EmailPrefsRepository) RequestPendingAddress(ctx context.Context, userID int, profileID, email, tokenHash string, expiresAt time.Time) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin pending address tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var lastSentAt *time.Time
|
||||
var sendsToday int
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT pending_last_sent_at, verify_sends_today
|
||||
FROM notification_email_prefs WHERE profile_id = $1 FOR UPDATE`,
|
||||
profileID,
|
||||
).Scan(&lastSentAt, &sendsToday)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("read verify rate state: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if lastSentAt != nil && now.Sub(*lastSentAt) < emailVerifyMinInterval {
|
||||
return ErrEmailVerifyRateLimited
|
||||
}
|
||||
if lastSentAt == nil || !sameUTCDay(*lastSentAt, now) {
|
||||
sendsToday = 0
|
||||
}
|
||||
if sendsToday >= emailVerifyDailyCap {
|
||||
return ErrEmailVerifyRateLimited
|
||||
}
|
||||
|
||||
// Friendly early rejection; the authoritative check re-runs at verify
|
||||
// time, so a conflict that appears in between still cannot land.
|
||||
inUse, err := addressInUse(ctx, tx, email, profileID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inUse {
|
||||
return ErrEmailAddressInUse
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO notification_email_prefs
|
||||
(profile_id, user_id, pending_email, pending_token_hash,
|
||||
pending_expires_at, pending_last_sent_at, verify_sends_today)
|
||||
VALUES ($1, $2, $3, $4, $5, now(), $6)
|
||||
ON CONFLICT (profile_id) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
pending_email = EXCLUDED.pending_email,
|
||||
pending_token_hash = EXCLUDED.pending_token_hash,
|
||||
pending_expires_at = EXCLUDED.pending_expires_at,
|
||||
pending_last_sent_at = now(),
|
||||
verify_sends_today = EXCLUDED.verify_sends_today,
|
||||
updated_at = now()`,
|
||||
profileID, userID, email, tokenHash, expiresAt, sendsToday+1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store pending address: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// sameUTCDay reports whether both instants fall on the same UTC calendar day.
|
||||
func sameUTCDay(a, b time.Time) bool {
|
||||
ay, am, ad := a.UTC().Date()
|
||||
by, bm, bd := b.UTC().Date()
|
||||
return ay == by && am == bm && ad == bd
|
||||
}
|
||||
|
||||
// EmailVerifyOutcome classifies one verification-link click.
|
||||
type EmailVerifyOutcome int
|
||||
|
||||
const (
|
||||
// EmailVerifyInvalid: unknown, already-used, or expired token.
|
||||
EmailVerifyInvalid EmailVerifyOutcome = iota
|
||||
// EmailVerifyOK: the pending address is now the verified destination.
|
||||
EmailVerifyOK
|
||||
// EmailVerifyConflict: the address was claimed by another profile or
|
||||
// account after the verification email went out.
|
||||
EmailVerifyConflict
|
||||
)
|
||||
|
||||
// ConsumeVerifyToken promotes the pending address matching the token hash to
|
||||
// the verified custom address. Single-use: the pending state is cleared on
|
||||
// every outcome except invalid. Uniqueness is re-checked here — request-time
|
||||
// checks can be raced — and the partial unique index on lower(custom_email)
|
||||
// backstops concurrent verifications of the same address.
|
||||
func (r *EmailPrefsRepository) ConsumeVerifyToken(ctx context.Context, tokenHash string) (EmailVerifyOutcome, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return EmailVerifyInvalid, fmt.Errorf("begin verify tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var profileID, pendingEmail string
|
||||
var userID int
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT profile_id, user_id, pending_email
|
||||
FROM notification_email_prefs
|
||||
WHERE pending_token_hash = $1 AND pending_token_hash <> ''
|
||||
AND pending_email <> ''
|
||||
AND (pending_expires_at IS NULL OR pending_expires_at > now())
|
||||
FOR UPDATE`,
|
||||
tokenHash,
|
||||
).Scan(&profileID, &userID, &pendingEmail)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return EmailVerifyInvalid, nil
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVerifyInvalid, fmt.Errorf("look up verify token: %w", err)
|
||||
}
|
||||
|
||||
clearPending := func(outcome EmailVerifyOutcome) (EmailVerifyOutcome, error) {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE notification_email_prefs SET
|
||||
pending_email = '',
|
||||
pending_token_hash = '',
|
||||
pending_expires_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE profile_id = $1`,
|
||||
profileID)
|
||||
if err != nil {
|
||||
return EmailVerifyInvalid, fmt.Errorf("clear pending address: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return EmailVerifyInvalid, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
inUse, err := addressInUse(ctx, tx, pendingEmail, profileID, userID)
|
||||
if err != nil {
|
||||
return EmailVerifyInvalid, err
|
||||
}
|
||||
if inUse {
|
||||
return clearPending(EmailVerifyConflict)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE notification_email_prefs SET
|
||||
custom_email = pending_email,
|
||||
pending_email = '',
|
||||
pending_token_hash = '',
|
||||
pending_expires_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE profile_id = $1`,
|
||||
profileID)
|
||||
if isUniqueViolation(err) {
|
||||
// Lost a same-instant race with another profile verifying the same
|
||||
// address; the unique index decided the winner.
|
||||
_ = tx.Rollback(ctx)
|
||||
return r.consumeConflictLoser(ctx, profileID)
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVerifyInvalid, fmt.Errorf("consume verify token: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return EmailVerifyInvalid, err
|
||||
}
|
||||
return EmailVerifyOK, nil
|
||||
}
|
||||
|
||||
// consumeConflictLoser clears the pending state of a profile that lost a
|
||||
// concurrent-verification race, in a fresh transaction (the racing one is
|
||||
// poisoned by the constraint violation).
|
||||
func (r *EmailPrefsRepository) consumeConflictLoser(ctx context.Context, profileID string) (EmailVerifyOutcome, error) {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE notification_email_prefs SET
|
||||
pending_email = '',
|
||||
pending_token_hash = '',
|
||||
pending_expires_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE profile_id = $1`,
|
||||
profileID)
|
||||
if err != nil {
|
||||
return EmailVerifyInvalid, fmt.Errorf("clear losing pending address: %w", err)
|
||||
}
|
||||
return EmailVerifyConflict, nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique-constraint
|
||||
// violation (SQLSTATE 23505).
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
// ClearCustomAddress removes the verified address and any in-flight
|
||||
// verification. The channel switches off in the same statement: without an
|
||||
// address there is no destination, and leaving the mode on would silently
|
||||
// re-arm delivery the moment a new address verifies.
|
||||
func (r *EmailPrefsRepository) ClearCustomAddress(ctx context.Context, profileID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE notification_email_prefs SET
|
||||
custom_email = '',
|
||||
pending_email = '',
|
||||
pending_token_hash = '',
|
||||
pending_expires_at = NULL,
|
||||
mode = 'off',
|
||||
updated_at = now()
|
||||
WHERE profile_id = $1`,
|
||||
profileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear custom address: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeByToken switches the matching profile's email mode off. ok is
|
||||
// false when no row carries the token.
|
||||
func (r *EmailPrefsRepository) UnsubscribeByToken(ctx context.Context, token string) (ok bool, err error) {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE notification_email_prefs SET
|
||||
mode = 'off',
|
||||
updated_at = now()
|
||||
WHERE unsubscribe_token = $1 AND unsubscribe_token <> ''`,
|
||||
token)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unsubscribe by token: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
// DeleteForProfile purges the profile's email prefs (profile deletion).
|
||||
func (r *EmailPrefsRepository) DeleteForProfile(ctx context.Context, profileID string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM notification_email_prefs WHERE profile_id = $1`, profileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete email prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListActiveRecipients returns every profile with email notifications on and
|
||||
// a verified destination address. Disabled or deleted accounts drop out of
|
||||
// the join.
|
||||
func (r *EmailPrefsRepository) ListActiveRecipients(ctx context.Context) ([]accountRecipient[string], error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT p.profile_id, p.mode, p.watermark_created_at, p.watermark_id,
|
||||
p.last_digest_at, p.last_attempt_at, p.consecutive_failures
|
||||
FROM notification_email_prefs p
|
||||
JOIN users u ON u.id = p.user_id AND u.enabled
|
||||
WHERE p.mode <> 'off' AND p.custom_email <> ''
|
||||
ORDER BY p.profile_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list email recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]accountRecipient[string], 0, 8)
|
||||
for rows.Next() {
|
||||
var rec accountRecipient[string]
|
||||
if err := rows.Scan(&rec.Key, &rec.Mode, &rec.WatermarkCreatedAt, &rec.WatermarkID,
|
||||
&rec.LastDigestAt, &rec.LastAttemptAt, &rec.ConsecutiveFailures); err != nil {
|
||||
return nil, fmt.Errorf("scan email recipient: %w", err)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// claimForUpdate locks the profile's prefs row for one dispatch attempt.
|
||||
// SKIP LOCKED makes concurrent nodes pass over each other's in-flight
|
||||
// profiles instead of double-sending; (nil, nil) means another node holds
|
||||
// the row.
|
||||
func (r *EmailPrefsRepository) claimForUpdate(ctx context.Context, tx pgx.Tx, profileID string) (*accountRecipient[string], error) {
|
||||
rec := accountRecipient[string]{Key: profileID}
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT mode, watermark_created_at, watermark_id, last_digest_at,
|
||||
last_attempt_at, consecutive_failures
|
||||
FROM notification_email_prefs
|
||||
WHERE profile_id = $1
|
||||
FOR UPDATE SKIP LOCKED`,
|
||||
profileID,
|
||||
).Scan(&rec.Mode, &rec.WatermarkCreatedAt, &rec.WatermarkID,
|
||||
&rec.LastDigestAt, &rec.LastAttemptAt, &rec.ConsecutiveFailures)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim email prefs: %w", err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// destinationForSend resolves where the locked profile's email goes — its
|
||||
// verified address — plus the row's owner and unsubscribe token. Read under
|
||||
// the claim so a mid-pass address removal fails cleanly instead of sending
|
||||
// to a stale recipient.
|
||||
func (r *EmailPrefsRepository) destinationForSend(ctx context.Context, tx pgx.Tx, profileID string) (email string, userID int, unsubscribeToken string, err error) {
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT p.custom_email, p.user_id, p.unsubscribe_token
|
||||
FROM notification_email_prefs p
|
||||
JOIN users u ON u.id = p.user_id AND u.enabled
|
||||
WHERE p.profile_id = $1`,
|
||||
profileID,
|
||||
).Scan(&email, &userID, &unsubscribeToken)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", 0, "", fmt.Errorf("profile %s has no usable account", profileID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, "", fmt.Errorf("resolve email destination: %w", err)
|
||||
}
|
||||
return email, userID, unsubscribeToken, nil
|
||||
}
|
||||
|
||||
// setUnsubscribeToken backfills a missing unsubscribe token under the claim
|
||||
// lock (rows migrated from the account-level table start without one).
|
||||
func (r *EmailPrefsRepository) setUnsubscribeToken(ctx context.Context, tx pgx.Tx, profileID, token string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE notification_email_prefs SET unsubscribe_token = $2, updated_at = now()
|
||||
WHERE profile_id = $1 AND unsubscribe_token = ''`,
|
||||
profileID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set unsubscribe token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markSent advances the watermark past everything the email covered and
|
||||
// resets failure backoff. digestAt is non-nil for digest sends (including
|
||||
// empty digests, so eligibility stops re-checking until tomorrow).
|
||||
func (r *EmailPrefsRepository) markSent(ctx context.Context, tx pgx.Tx, profileID string, watermark Cursor, digestAt *time.Time) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE notification_email_prefs SET
|
||||
watermark_created_at = $2,
|
||||
watermark_id = $3,
|
||||
last_digest_at = COALESCE($4, last_digest_at),
|
||||
last_attempt_at = now(),
|
||||
consecutive_failures = 0,
|
||||
updated_at = now()
|
||||
WHERE profile_id = $1`,
|
||||
profileID, watermark.CreatedAt, watermark.ID, digestAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark email sent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markFailure records a failed send for backoff; the watermark stays put so
|
||||
// the next eligible pass retries the same items.
|
||||
func (r *EmailPrefsRepository) markFailure(ctx context.Context, tx pgx.Tx, profileID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE notification_email_prefs SET
|
||||
last_attempt_at = now(),
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
updated_at = now()
|
||||
WHERE profile_id = $1`,
|
||||
profileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark email failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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()
|
||||
}
|
||||
|
||||
// PartitionEventsByKind splits claimed events into episode events (which fan
|
||||
// out to profiles) and everything else (movie events, which only feed the
|
||||
// server-channel broadcast sweep). Order is preserved within each partition.
|
||||
// The common all-episode batch returns the input slice unchanged.
|
||||
func PartitionEventsByKind(events []ReleaseEvent) (episodes, others []ReleaseEvent) {
|
||||
allEpisodes := true
|
||||
for _, event := range events {
|
||||
if normalizeEventKind(event.Kind) != EventKindEpisode {
|
||||
allEpisodes = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allEpisodes {
|
||||
return events, nil
|
||||
}
|
||||
episodes = make([]ReleaseEvent, 0, len(events))
|
||||
others = make([]ReleaseEvent, 0)
|
||||
for _, event := range events {
|
||||
if normalizeEventKind(event.Kind) == EventKindEpisode {
|
||||
episodes = append(episodes, event)
|
||||
} else {
|
||||
others = append(others, event)
|
||||
}
|
||||
}
|
||||
return episodes, others
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
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) }()
|
||||
|
||||
claimed, err := w.releases.ClaimUnprocessed(ctx, tx, settle, fanoutClaimLimit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(claimed) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Non-episode kinds (movies) have no per-profile interest and never fan
|
||||
// out; mark them processed immediately so retention reclaims them. This
|
||||
// must happen before the burst cap: movie events have no series_id, and
|
||||
// ApplyBurstCap groups by (library_id, series_id). The server-channel
|
||||
// sweep reads events by cursor regardless of processed state.
|
||||
events, others := PartitionEventsByKind(claimed)
|
||||
if err := w.releases.MarkProcessed(ctx, tx, eventIDs(others), nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 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(claimed),
|
||||
"non_episode", len(others),
|
||||
"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(claimed), 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,
|
||||
PosterSourcePath: display.posterSourcePath,
|
||||
})
|
||||
}
|
||||
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
|
||||
posterSourcePath 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, ''),
|
||||
COALESCE(s.poster_source_path, '')
|
||||
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, &display.posterSourcePath)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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, 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):
|
||||
// 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
|
||||
}
|
||||
|
||||
// --- 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
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
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
|
||||
// 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 {
|
||||
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 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.
|
||||
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]int),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
mutation := interestMutation{userID: userID, profileID: profileID, itemID: itemID}
|
||||
u.mu.Lock()
|
||||
if _, queued := u.pending[mutation]; !queued {
|
||||
u.pending[mutation] = 0
|
||||
}
|
||||
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]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 {
|
||||
userID int
|
||||
profileID string
|
||||
seriesID string
|
||||
}
|
||||
seen := make(map[recomputeKey]struct{}, len(batch))
|
||||
for mutation, failures := range batch {
|
||||
if ctx.Err() != nil {
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
// 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
|
||||
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])
|
||||
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 {
|
||||
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 /
|
||||
// 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// IsContentSeeded reports whether availability seeding completed for the
|
||||
// library and content kind. Episodes keep the legacy single-purpose table;
|
||||
// every later kind shares notification_content_seed_state. The split is
|
||||
// load-bearing: episode seeding already marked movie libraries (with zero
|
||||
// movie rows), so reusing those markers would flood a kind's back catalog on
|
||||
// its first post-upgrade scan.
|
||||
func (r *ReleaseRepository) IsContentSeeded(ctx context.Context, libraryID int, kind string) (bool, error) {
|
||||
if kind == EventKindEpisode {
|
||||
return r.IsLibrarySeeded(ctx, libraryID)
|
||||
}
|
||||
var seeded bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM notification_content_seed_state WHERE library_id = $1 AND kind = $2)`,
|
||||
libraryID, kind,
|
||||
).Scan(&seeded)
|
||||
return seeded, err
|
||||
}
|
||||
|
||||
// MarkContentSeeded records that availability seeding completed for the
|
||||
// library and content kind. Idempotent.
|
||||
func (r *ReleaseRepository) MarkContentSeeded(ctx context.Context, libraryID int, kind string) error {
|
||||
if kind == EventKindEpisode {
|
||||
return r.MarkLibrarySeeded(ctx, libraryID)
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO notification_content_seed_state (library_id, kind, seeded_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (library_id, kind) DO NOTHING`, libraryID, kind)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordMovieAvailabilityForLibrary inserts movie_availability rows for every
|
||||
// movie currently present in the library (one-way, idempotent) and, when
|
||||
// emitEvents is true, creates movie release events for the newly inserted
|
||||
// rows. Returns (availability rows inserted, release events created).
|
||||
func (r *ReleaseRepository) RecordMovieAvailabilityForLibrary(ctx context.Context, libraryID int, emitEvents bool) (int, int, error) {
|
||||
query := `
|
||||
INSERT INTO movie_availability (library_id, item_id)
|
||||
SELECT mil.media_folder_id, mi.content_id
|
||||
FROM media_item_libraries mil
|
||||
JOIN media_items mi ON mi.content_id = mil.content_id AND mi.type = 'movie'
|
||||
WHERE mil.media_folder_id = $1
|
||||
ON CONFLICT (library_id, item_id) DO NOTHING
|
||||
RETURNING item_id, available_at`
|
||||
return r.recordMovieAvailability(ctx, libraryID, emitEvents, query, []any{libraryID})
|
||||
}
|
||||
|
||||
// RecordMovieAvailabilityForPaths inserts availability rows for movies whose
|
||||
// playable files live under the given scope paths (subtree/file ingest), and
|
||||
// optionally creates release events for newly inserted rows.
|
||||
func (r *ReleaseRepository) RecordMovieAvailabilityForPaths(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 movie_availability (library_id, item_id)
|
||||
SELECT DISTINCT mf.media_folder_id, mi.content_id
|
||||
FROM media_files mf
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id AND mi.type = 'movie'
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.episode_id IS NULL
|
||||
AND mf.content_id IS NOT NULL
|
||||
AND (` + strings.Join(scopeConds, " OR ") + `)
|
||||
ON CONFLICT (library_id, item_id) DO NOTHING
|
||||
RETURNING item_id, available_at`
|
||||
return r.recordMovieAvailability(ctx, libraryID, emitEvents, query, args)
|
||||
}
|
||||
|
||||
// recordMovieAvailability is the movie counterpart of recordAvailability: insert
|
||||
// availability facts and the optional release events in one transaction.
|
||||
func (r *ReleaseRepository) recordMovieAvailability(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 movie 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 movie availability: %w", err)
|
||||
}
|
||||
type newMovie struct {
|
||||
ItemID string
|
||||
AvailableAt time.Time
|
||||
}
|
||||
inserted := make([]newMovie, 0, 16)
|
||||
for rows.Next() {
|
||||
var row newMovie
|
||||
if err := rows.Scan(&row.ItemID, &row.AvailableAt); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, fmt.Errorf("scan inserted movie availability: %w", err)
|
||||
}
|
||||
inserted = append(inserted, row)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, 0, fmt.Errorf("read inserted movie availability: %w", err)
|
||||
}
|
||||
|
||||
events := 0
|
||||
if emitEvents && len(inserted) > 0 {
|
||||
const chunkSize = 500
|
||||
for start := 0; start < len(inserted); start += chunkSize {
|
||||
end := min(start+chunkSize, len(inserted))
|
||||
chunk := inserted[start:end]
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`
|
||||
INSERT INTO release_events
|
||||
(id, library_id, kind, item_id, available_at, dedupe_key)
|
||||
VALUES `)
|
||||
eventArgs := make([]any, 0, len(chunk)*6)
|
||||
for i, row := range chunk {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
base := len(eventArgs)
|
||||
sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d)",
|
||||
base+1, base+2, base+3, base+4, base+5, base+6))
|
||||
eventArgs = append(eventArgs,
|
||||
ulid.Make().String(),
|
||||
libraryID,
|
||||
EventKindMovie,
|
||||
row.ItemID,
|
||||
row.AvailableAt,
|
||||
MovieDedupeKey(libraryID, row.ItemID),
|
||||
)
|
||||
}
|
||||
sb.WriteString(" ON CONFLICT (dedupe_key) DO NOTHING")
|
||||
tag, err := tx.Exec(ctx, sb.String(), eventArgs...)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("insert movie release events: %w", err)
|
||||
}
|
||||
events += int(tag.RowsAffected())
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, 0, fmt.Errorf("commit movie availability tx: %w", err)
|
||||
}
|
||||
return len(inserted), events, nil
|
||||
}
|
||||
|
||||
// MovieDedupeKey composes the release_events dedupe key for a movie. The
|
||||
// "movie:" prefix keeps the keyspace disjoint from episode keys
|
||||
// ("{library_id}:{episode_id}").
|
||||
func MovieDedupeKey(libraryID int, itemID string) string {
|
||||
return fmt.Sprintf("movie:%d:%s", libraryID, itemID)
|
||||
}
|
||||
|
||||
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 `+releaseEventColumns+`
|
||||
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()
|
||||
return scanReleaseEvents(rows, limit)
|
||||
}
|
||||
|
||||
// releaseEventColumns is the shared event SELECT list. Episode columns are
|
||||
// nullable since the movie kind landed; COALESCE keeps episode rows scanning
|
||||
// into the flat struct and movie rows reading as zero values.
|
||||
const releaseEventColumns = `id, library_id, kind, COALESCE(item_id, ''),
|
||||
COALESCE(series_id, ''), COALESCE(episode_id, ''),
|
||||
COALESCE(season_number, 0), COALESCE(episode_number, 0),
|
||||
COALESCE(episode_key, 0), available_at, dedupe_key, created_at`
|
||||
|
||||
func scanReleaseEvents(rows pgx.Rows, capacityHint int) ([]ReleaseEvent, error) {
|
||||
events := make([]ReleaseEvent, 0, capacityHint)
|
||||
for rows.Next() {
|
||||
var event ReleaseEvent
|
||||
if err := rows.Scan(
|
||||
&event.ID, &event.LibraryID, &event.Kind, &event.ItemID,
|
||||
&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()
|
||||
}
|
||||
|
||||
// HasEventsSince cheaply reports whether any release event matured past the
|
||||
// batch window exists beyond the cursor, so idle server channels don't open a
|
||||
// claim transaction every sweep pass. Shares ListEventsSince's predicate.
|
||||
func (r *ReleaseRepository) HasEventsSince(ctx context.Context, since Cursor, batchAge time.Duration) (bool, error) {
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM release_events
|
||||
WHERE (created_at, id) > ($1, $2)
|
||||
AND created_at <= now() - ($3 * interval '1 second')
|
||||
)`,
|
||||
since.CreatedAt, since.ID, batchAge.Seconds()).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check pending release events: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ListEventsSince returns release events past the (created_at, id) cursor in
|
||||
// sweep order, regardless of processed/suppressed state: the server-channel
|
||||
// broadcast feed wants burst-suppressed episodes too (grouping absorbs the
|
||||
// volume). batchAge holds back rows younger than the batch window so an
|
||||
// in-flight availability transaction can never commit behind the watermark.
|
||||
// Must run inside the caller's transaction holding the channel claim.
|
||||
func (r *ReleaseRepository) ListEventsSince(ctx context.Context, tx pgx.Tx, since Cursor, batchAge time.Duration, limit int) ([]ReleaseEvent, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT `+releaseEventColumns+`
|
||||
FROM release_events
|
||||
WHERE (created_at, id) > ($1, $2)
|
||||
AND created_at <= now() - ($3 * interval '1 second')
|
||||
ORDER BY created_at, id
|
||||
LIMIT $4`,
|
||||
since.CreatedAt, since.ID, batchAge.Seconds(), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list release events since cursor: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanReleaseEvents(rows, limit)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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"
|
||||
|
||||
// Release event kinds. Episode events carry the series/episode columns and
|
||||
// fan out to interested profiles; movie events carry ItemID only and exist
|
||||
// for the server-channel broadcast feed (no per-profile fanout in v1).
|
||||
const (
|
||||
EventKindEpisode = "episode"
|
||||
EventKindMovie = "movie"
|
||||
)
|
||||
|
||||
// normalizeEventKind treats an unset kind as episode — the single home of
|
||||
// that rule. The column is NOT NULL DEFAULT 'episode', so only in-memory
|
||||
// constructed events can carry an empty kind.
|
||||
func normalizeEventKind(kind string) string {
|
||||
if kind == "" {
|
||||
return EventKindEpisode
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
// ReleaseEvent is one logical "content became newly available in a library"
|
||||
// event. dedupe_key is "{library_id}:{episode_id}" for episodes and
|
||||
// "movie:{library_id}:{item_id}" for movies.
|
||||
type ReleaseEvent struct {
|
||||
ID string
|
||||
LibraryID int
|
||||
Kind string
|
||||
// ItemID is the media_items content id for movie events; empty for
|
||||
// episode events.
|
||||
ItemID string
|
||||
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
|
||||
// PosterSourcePath is the provider-origin artwork path preserved when
|
||||
// image caching rewrote PosterPath to a local storage key; embeds fall
|
||||
// back to it to build public provider-CDN poster URLs.
|
||||
PosterSourcePath string
|
||||
// PosterURL is the fetchable poster URL chosen for outbound embeds by
|
||||
// the sender layer (System.discordPosterURL). Transient — never read
|
||||
// from or written to the database; empty renders the embed without an
|
||||
// image.
|
||||
PosterURL string
|
||||
// Catalog enrichment rendered by outbound channels (Discord embeds).
|
||||
// Series-level fields describe the joined media_items row, which for
|
||||
// request.fulfilled deliveries is the matched movie or series itself.
|
||||
MediaType string // media_items.type: "movie" | "series"
|
||||
Year int
|
||||
SeriesOverview string
|
||||
EpisodeOverview string
|
||||
Genres []string
|
||||
ContentRating string
|
||||
RatingIMDB float64
|
||||
RatingTMDB float64
|
||||
IMDBID string
|
||||
TMDBID string
|
||||
TVDBID 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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
// Server-channel broadcast first: it is community-facing and must not be
|
||||
// gated by the requester's personal preferences or attribution. Detached
|
||||
// and best-effort — a failure here must never block the
|
||||
// fulfilled_notified_at stamp, or the per-profile path would re-fire.
|
||||
n.system.PostServerChannelRequestEvent(ctx, ServerChannelEventRequestFulfilled, requestEventInfoFor(req))
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testMovieTitle = "Dune"
|
||||
testSeriesTitle = "Severance"
|
||||
testSeriesPosterCDN = "https://image.tmdb.org/t/p/w500/severance.jpg"
|
||||
testSeriesPosterPath = "tmdb://poster/severance.jpg"
|
||||
)
|
||||
|
||||
func episodeEvent(id string, libraryID int, seriesID string, season, episode int) ReleaseEvent {
|
||||
return ReleaseEvent{
|
||||
ID: id,
|
||||
LibraryID: libraryID,
|
||||
Kind: EventKindEpisode,
|
||||
SeriesID: seriesID,
|
||||
EpisodeID: "ep-" + id,
|
||||
SeasonNumber: season,
|
||||
EpisodeNumber: episode,
|
||||
EpisodeKey: EpisodeKey(season, episode),
|
||||
}
|
||||
}
|
||||
|
||||
func movieEvent(id string, libraryID int, itemID string) ReleaseEvent {
|
||||
return ReleaseEvent{
|
||||
ID: id,
|
||||
LibraryID: libraryID,
|
||||
Kind: EventKindMovie,
|
||||
ItemID: itemID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupContentEvents(t *testing.T) {
|
||||
titles := map[string]ContentMeta{
|
||||
"series-1": {Title: testSeriesTitle},
|
||||
"movie-1": {Title: testMovieTitle, Year: 2026},
|
||||
}
|
||||
|
||||
t.Run("season pack groups into one entry with a range", func(t *testing.T) {
|
||||
events := []ReleaseEvent{
|
||||
episodeEvent("3", 1, "series-1", 2, 3),
|
||||
episodeEvent("1", 1, "series-1", 2, 1),
|
||||
episodeEvent("2", 1, "series-1", 2, 2),
|
||||
}
|
||||
groups := GroupContentEvents(events, titles)
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("got %d groups, want 1", len(groups))
|
||||
}
|
||||
if got := contentGroupTitle(groups[0]); got != "Severance — 3 new episodes (S2 E1–E3)" {
|
||||
t.Fatalf("unexpected group title %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single episode renders its code", func(t *testing.T) {
|
||||
groups := GroupContentEvents([]ReleaseEvent{episodeEvent("1", 1, "series-1", 2, 5)}, titles)
|
||||
if got := contentGroupTitle(groups[0]); got != "Severance — S2 E5" {
|
||||
t.Fatalf("unexpected group title %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-season range spells both codes", func(t *testing.T) {
|
||||
events := []ReleaseEvent{
|
||||
episodeEvent("1", 1, "series-1", 1, 10),
|
||||
episodeEvent("2", 1, "series-1", 2, 1),
|
||||
}
|
||||
groups := GroupContentEvents(events, titles)
|
||||
if got := contentGroupTitle(groups[0]); got != "Severance — 2 new episodes (S1 E10 – S2 E1)" {
|
||||
t.Fatalf("unexpected group title %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("movies render individually with year", func(t *testing.T) {
|
||||
groups := GroupContentEvents([]ReleaseEvent{movieEvent("1", 1, "movie-1")}, titles)
|
||||
if len(groups) != 1 || groups[0].Kind != EventKindMovie {
|
||||
t.Fatalf("unexpected groups %+v", groups)
|
||||
}
|
||||
if got := contentGroupTitle(groups[0]); got != "Dune (2026)" {
|
||||
t.Fatalf("unexpected movie title %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same movie in two libraries announces once", func(t *testing.T) {
|
||||
events := []ReleaseEvent{
|
||||
movieEvent("1", 1, "movie-1"),
|
||||
movieEvent("2", 2, "movie-1"),
|
||||
}
|
||||
if groups := GroupContentEvents(events, titles); len(groups) != 1 {
|
||||
t.Fatalf("got %d groups, want 1 (cross-library movie dedupe)", len(groups))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same series in two libraries stays separate", func(t *testing.T) {
|
||||
// Episode-level cross-library dedupe is the per-profile path's job;
|
||||
// the broadcast feed reflects each library's catalog.
|
||||
events := []ReleaseEvent{
|
||||
episodeEvent("1", 1, "series-1", 1, 1),
|
||||
episodeEvent("2", 2, "series-1", 1, 1),
|
||||
}
|
||||
if groups := GroupContentEvents(events, titles); len(groups) != 2 {
|
||||
t.Fatalf("got %d groups, want 2", len(groups))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing titles fall back to generic labels", func(t *testing.T) {
|
||||
groups := GroupContentEvents([]ReleaseEvent{
|
||||
episodeEvent("1", 1, "unknown-series", 1, 1),
|
||||
movieEvent("2", 1, "unknown-movie"),
|
||||
}, nil)
|
||||
if groups[0].Meta.Title != genericEpisodeTitle {
|
||||
t.Fatalf("unexpected series fallback %q", groups[0].Meta.Title)
|
||||
}
|
||||
if groups[1].Meta.Title != "New movie" {
|
||||
t.Fatalf("unexpected movie fallback %q", groups[1].Meta.Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pre-discriminator rows with empty kind group as episodes", func(t *testing.T) {
|
||||
event := episodeEvent("1", 1, "series-1", 1, 1)
|
||||
event.Kind = ""
|
||||
groups := GroupContentEvents([]ReleaseEvent{event}, titles)
|
||||
if len(groups) != 1 || groups[0].Kind != EventKindEpisode {
|
||||
t.Fatalf("unexpected groups %+v", groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerChannelWantsToggles(t *testing.T) {
|
||||
ch := ServerChannel{
|
||||
NotifyNewMovies: true,
|
||||
NotifyNewEpisodes: false,
|
||||
NotifyRequestSubmitted: true,
|
||||
NotifyRequestFulfilled: false,
|
||||
}
|
||||
cases := []struct {
|
||||
kind string
|
||||
want bool
|
||||
}{
|
||||
{EventKindMovie, true},
|
||||
{EventKindEpisode, false},
|
||||
{"", false}, // legacy rows follow the episode toggle
|
||||
{"audiobook", false}, // unknown future kinds are never announced
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ch.WantsContentKind(tc.kind); got != tc.want {
|
||||
t.Errorf("WantsContentKind(%q) = %v, want %v", tc.kind, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
eventCases := []struct {
|
||||
event string
|
||||
want bool
|
||||
}{
|
||||
{ServerChannelEventRequestSubmitted, true},
|
||||
{ServerChannelEventRequestApproved, false},
|
||||
{ServerChannelEventRequestDeclined, false},
|
||||
{ServerChannelEventRequestFulfilled, false},
|
||||
{"request.unknown", false},
|
||||
}
|
||||
for _, tc := range eventCases {
|
||||
if got := ch.WantsRequestEvent(tc.event); got != tc.want {
|
||||
t.Errorf("WantsRequestEvent(%q) = %v, want %v", tc.event, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerChannelDiscordContent(t *testing.T) {
|
||||
titles := map[string]ContentMeta{
|
||||
"series-1": {
|
||||
Title: testSeriesTitle,
|
||||
Type: "series",
|
||||
Overview: "Mark leads a team whose memories have been surgically divided.",
|
||||
PosterPath: testSeriesPosterPath,
|
||||
PosterURL: testSeriesPosterCDN,
|
||||
Genres: []string{"Drama", "Sci-Fi & Fantasy", "Mystery", "Thriller"},
|
||||
ContentRating: "TV-MA",
|
||||
RatingIMDB: 8.7,
|
||||
IMDBID: "tt11280740",
|
||||
TMDBID: "95396",
|
||||
TVDBID: "371980",
|
||||
},
|
||||
"movie-1": {Title: testMovieTitle, Year: 2026, Type: "movie"},
|
||||
}
|
||||
groups := GroupContentEvents([]ReleaseEvent{
|
||||
episodeEvent("1", 1, "series-1", 2, 1),
|
||||
episodeEvent("2", 1, "series-1", 2, 2),
|
||||
movieEvent("3", 1, "movie-1"),
|
||||
}, titles)
|
||||
|
||||
body, err := BuildServerChannelDiscordContent(groups, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded struct {
|
||||
Content string `json:"content"`
|
||||
Username string `json:"username"`
|
||||
Embeds []discordEmbed `json:"embeds"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded.Embeds) != 2 {
|
||||
t.Fatalf("got %d embeds, want 2", len(decoded.Embeds))
|
||||
}
|
||||
if decoded.Username != "Silo" {
|
||||
t.Fatalf("unexpected username %q", decoded.Username)
|
||||
}
|
||||
if decoded.Content != "" {
|
||||
t.Fatalf("no overflow expected, got content %q", decoded.Content)
|
||||
}
|
||||
|
||||
episodes := decoded.Embeds[0]
|
||||
if episodes.Author == nil || episodes.Author.Name != "New episodes available on Silo" {
|
||||
t.Fatalf("unexpected author %+v", episodes.Author)
|
||||
}
|
||||
if episodes.URL != "https://www.themoviedb.org/tv/95396" {
|
||||
t.Fatalf("unexpected title URL %q", episodes.URL)
|
||||
}
|
||||
if episodes.Thumbnail == nil || episodes.Thumbnail.URL != testSeriesPosterCDN {
|
||||
t.Fatalf("unexpected thumbnail %+v", episodes.Thumbnail)
|
||||
}
|
||||
if !strings.HasPrefix(episodes.Description, "Mark leads a team") ||
|
||||
!strings.Contains(episodes.Description, "[IMDb](https://www.imdb.com/title/tt11280740/)") ||
|
||||
!strings.Contains(episodes.Description, "[TVDB](https://thetvdb.com/dereferrer/series/371980)") {
|
||||
t.Fatalf("unexpected description %q", episodes.Description)
|
||||
}
|
||||
if len(episodes.Fields) != 2 ||
|
||||
episodes.Fields[0].Value != "★ 8.7 IMDb" ||
|
||||
episodes.Fields[1].Value != "Drama, Sci-Fi & Fantasy, Mystery" {
|
||||
t.Fatalf("unexpected fields %+v", episodes.Fields)
|
||||
}
|
||||
if episodes.Footer == nil || episodes.Footer.Text != "Silo • TV-MA" {
|
||||
t.Fatalf("unexpected footer %+v", episodes.Footer)
|
||||
}
|
||||
|
||||
// Embeds may name public provider origins only — never this server's.
|
||||
movie := decoded.Embeds[1]
|
||||
if movie.URL != "" || movie.Thumbnail != nil {
|
||||
t.Fatalf("metadata-less movie must omit url/thumbnail, got %+v", movie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerChannelDiscordContentOverflow(t *testing.T) {
|
||||
groups := make([]ContentGroup, 0, 14)
|
||||
for i := 0; i < 14; i++ {
|
||||
groups = append(groups, ContentGroup{
|
||||
Kind: EventKindMovie,
|
||||
ItemID: "movie",
|
||||
Meta: ContentMeta{Title: "Movie", Year: 2000 + i},
|
||||
})
|
||||
}
|
||||
body, err := BuildServerChannelDiscordContent(groups, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded struct {
|
||||
Content string `json:"content"`
|
||||
Embeds []discordEmbed `json:"embeds"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded.Embeds) != serverChannelMaxEmbeds {
|
||||
t.Fatalf("got %d embeds, want %d", len(decoded.Embeds), serverChannelMaxEmbeds)
|
||||
}
|
||||
if !strings.Contains(decoded.Content, "4 more") {
|
||||
t.Fatalf("overflow line missing, got %q", decoded.Content)
|
||||
}
|
||||
// Newest groups are kept: the first four (oldest) drop.
|
||||
if decoded.Embeds[0].Title != "Movie (2004)" {
|
||||
t.Fatalf("expected oldest retained embed to be Movie (2004), got %q", decoded.Embeds[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerChannelGenericContent(t *testing.T) {
|
||||
titles := map[string]ContentMeta{"series-1": {Title: testSeriesTitle}, "movie-1": {Title: testMovieTitle, Year: 2026}}
|
||||
groups := GroupContentEvents([]ReleaseEvent{
|
||||
episodeEvent("1", 7, "series-1", 2, 1),
|
||||
episodeEvent("2", 7, "series-1", 2, 3),
|
||||
movieEvent("3", 7, "movie-1"),
|
||||
}, titles)
|
||||
|
||||
body, err := BuildServerChannelGenericContent(groups, "chan-1", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded serverChannelContentBody
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Event != ServerChannelEventContentAdded || decoded.ChannelID != "chan-1" || !decoded.Test {
|
||||
t.Fatalf("unexpected envelope %+v", decoded)
|
||||
}
|
||||
if len(decoded.Items) != 2 {
|
||||
t.Fatalf("got %d items, want 2", len(decoded.Items))
|
||||
}
|
||||
episodes := decoded.Items[0]
|
||||
if episodes.SeriesTitle != testSeriesTitle || episodes.EpisodeCount != 2 ||
|
||||
episodes.FirstSeason != 2 || episodes.FirstEpisode != 1 ||
|
||||
episodes.LastSeason != 2 || episodes.LastEpisode != 3 {
|
||||
t.Fatalf("unexpected episode item %+v", episodes)
|
||||
}
|
||||
movie := decoded.Items[1]
|
||||
if movie.Kind != EventKindMovie || movie.Title != testMovieTitle || movie.Year != 2026 || movie.LibraryID != 7 {
|
||||
t.Fatalf("unexpected movie item %+v", movie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerChannelRequestPayloads(t *testing.T) {
|
||||
info := RequestEventInfo{
|
||||
RequestID: "req-1",
|
||||
TMDBID: 42,
|
||||
MediaType: "movie",
|
||||
Title: testMovieTitle,
|
||||
Year: 2026,
|
||||
Overview: "Paul Atreides unites with the Fremen.",
|
||||
PosterPath: "/dune.jpg",
|
||||
RequesterName: "quick",
|
||||
}
|
||||
|
||||
body, err := BuildServerChannelRequestDiscord(ServerChannelEventRequestSubmitted, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var discord discordWebhookBody
|
||||
if err := json.Unmarshal(body, &discord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(discord.Embeds) != 1 {
|
||||
t.Fatalf("got %d embeds, want 1", len(discord.Embeds))
|
||||
}
|
||||
embed := discord.Embeds[0]
|
||||
if embed.Title != "Dune (2026)" {
|
||||
t.Fatalf("unexpected title %q", embed.Title)
|
||||
}
|
||||
if embed.Author == nil || embed.Author.Name != "New media request on Silo" {
|
||||
t.Fatalf("unexpected author %+v", embed.Author)
|
||||
}
|
||||
if embed.URL != "https://www.themoviedb.org/movie/42" {
|
||||
t.Fatalf("unexpected title URL %q", embed.URL)
|
||||
}
|
||||
if !strings.HasPrefix(embed.Description, "Paul Atreides unites") ||
|
||||
!strings.Contains(embed.Description, "[TMDB](https://www.themoviedb.org/movie/42)") {
|
||||
t.Fatalf("unexpected description %q", embed.Description)
|
||||
}
|
||||
if embed.Thumbnail == nil || embed.Thumbnail.URL != "https://image.tmdb.org/t/p/w500/dune.jpg" {
|
||||
t.Fatalf("unexpected thumbnail %+v", embed.Thumbnail)
|
||||
}
|
||||
if len(embed.Fields) != 2 || embed.Fields[1].Value != "quick" {
|
||||
t.Fatalf("unexpected fields %+v", embed.Fields)
|
||||
}
|
||||
|
||||
generic, err := BuildServerChannelRequestGeneric(ServerChannelEventRequestDeclined, info, "chan-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded serverChannelRequestBody
|
||||
if err := json.Unmarshal(generic, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Event != ServerChannelEventRequestDeclined || decoded.Request.ID != "req-1" ||
|
||||
decoded.Request.RequesterName != "quick" {
|
||||
t.Fatalf("unexpected generic body %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerChannelHeadersSigned(t *testing.T) {
|
||||
body := []byte(`{"event":"content.added"}`)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
headers := serverChannelHeaders(ServerChannelEventContentAdded, "chan-1", "secret", now, body)
|
||||
if headers["X-Silo-Event"] != ServerChannelEventContentAdded {
|
||||
t.Fatalf("unexpected event header %q", headers["X-Silo-Event"])
|
||||
}
|
||||
if headers["X-Silo-Channel-Id"] != "chan-1" {
|
||||
t.Fatalf("unexpected channel header %q", headers["X-Silo-Channel-Id"])
|
||||
}
|
||||
want := SignGenericWebhook("secret", now.Unix(), body)
|
||||
if headers["X-Silo-Signature"] != want {
|
||||
t.Fatalf("signature mismatch: %q != %q", headers["X-Silo-Signature"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovieDedupeKeyDisjointFromEpisodeKeys(t *testing.T) {
|
||||
// Episode dedupe keys are "{library_id}:{episode_id}". A movie key must
|
||||
// never collide even if a movie item id equals an episode id.
|
||||
if MovieDedupeKey(3, "abc") == "3:abc" {
|
||||
t.Fatal("movie dedupe keys must live in their own keyspace")
|
||||
}
|
||||
if got := MovieDedupeKey(3, "abc"); got != "movie:3:abc" {
|
||||
t.Fatalf("unexpected movie dedupe key %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionEventsByKind(t *testing.T) {
|
||||
legacy := episodeEvent("2", 1, "s", 1, 2)
|
||||
legacy.Kind = "" // rows that predate the kind column
|
||||
events := []ReleaseEvent{
|
||||
episodeEvent("1", 1, "s", 1, 1),
|
||||
legacy,
|
||||
movieEvent("3", 1, "m"),
|
||||
episodeEvent("4", 1, "s", 1, 3),
|
||||
}
|
||||
episodes, others := PartitionEventsByKind(events)
|
||||
if len(episodes) != 3 || len(others) != 1 {
|
||||
t.Fatalf("got %d/%d, want 3 episodes and 1 other", len(episodes), len(others))
|
||||
}
|
||||
if episodes[0].ID != "1" || episodes[1].ID != "2" || episodes[2].ID != "4" {
|
||||
t.Fatalf("episode order not preserved: %+v", episodes)
|
||||
}
|
||||
if others[0].ID != "3" {
|
||||
t.Fatalf("unexpected non-episode partition: %+v", others)
|
||||
}
|
||||
|
||||
// The fanout path applies the burst cap only to the episode partition;
|
||||
// movies with empty series_id must never reach it, or they would all
|
||||
// collapse into one (library, "") burst group.
|
||||
fanout, suppressed := ApplyBurstCap(episodes, 2)
|
||||
if len(fanout)+len(suppressed) != len(episodes) {
|
||||
t.Fatalf("burst cap lost events: %d + %d != %d", len(fanout), len(suppressed), len(episodes))
|
||||
}
|
||||
for _, event := range append(fanout, suppressed...) {
|
||||
if event.Kind == EventKindMovie {
|
||||
t.Fatal("movie event leaked into burst-capped fanout set")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxCursor(t *testing.T) {
|
||||
// Watermark advancement clamps through maxCursor so a watermark can never
|
||||
// move backward (used by the server-channel sweep and digest legs).
|
||||
earlier := Cursor{CreatedAt: time.Unix(1000, 0), ID: "a"}
|
||||
later := Cursor{CreatedAt: time.Unix(2000, 0), ID: "b"}
|
||||
if got := maxCursor(earlier, later); got != later {
|
||||
t.Fatalf("maxCursor(earlier, later) = %+v, want later", got)
|
||||
}
|
||||
if got := maxCursor(later, earlier); got != later {
|
||||
t.Fatalf("maxCursor(later, earlier) = %+v, want later", got)
|
||||
}
|
||||
// Same timestamp orders by id, matching the delivery queries.
|
||||
tie := Cursor{CreatedAt: time.Unix(2000, 0), ID: "c"}
|
||||
if got := maxCursor(later, tie); got != tie {
|
||||
t.Fatalf("maxCursor id tiebreak = %+v, want %+v", got, tie)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// siloSenderName labels Discord posts and embed footers.
|
||||
const siloSenderName = "Silo"
|
||||
|
||||
// Server-channel embed accent colors (decimal RGB).
|
||||
const (
|
||||
serverChannelColorContent = 5814783 // blurple — new content posts
|
||||
serverChannelColorSubmitted = 15844367 // gold — request submitted
|
||||
serverChannelColorApproved = 3066993 // green — request approved
|
||||
serverChannelColorDeclined = 15158332 // red — request declined
|
||||
serverChannelColorFulfilled = 5814783 // blurple — request fulfilled
|
||||
)
|
||||
|
||||
// ContentMeta is the display metadata for one catalog item (series or
|
||||
// movie), fetched in one batched query by the sweep worker. Only metadata a
|
||||
// Discord embed renders is carried; everything else stays in the catalog.
|
||||
type ContentMeta struct {
|
||||
Title string
|
||||
Year int
|
||||
Type string // media_items.type: "movie" | "series"
|
||||
Overview string
|
||||
PosterPath string
|
||||
// PosterSourcePath is the provider-origin path preserved when image
|
||||
// caching rewrote PosterPath to a local storage key.
|
||||
PosterSourcePath string
|
||||
// PosterURL is the fetchable poster URL chosen by the sweep worker
|
||||
// (System.discordPosterURL); empty renders the embed without an image.
|
||||
PosterURL string
|
||||
Genres []string
|
||||
ContentRating string
|
||||
RatingIMDB float64
|
||||
RatingTMDB float64
|
||||
IMDBID string
|
||||
TMDBID string
|
||||
TVDBID string
|
||||
}
|
||||
|
||||
func (m ContentMeta) providerIDs() providerIDs {
|
||||
return providerIDs{MediaType: m.Type, IMDB: m.IMDBID, TMDB: m.TMDBID, TVDB: m.TVDBID}
|
||||
}
|
||||
|
||||
// ContentGroup is one rendered unit of a content digest: a movie, or every
|
||||
// new episode of one series in the batch.
|
||||
type ContentGroup struct {
|
||||
Kind string // EventKindEpisode | EventKindMovie
|
||||
LibraryID int
|
||||
// Episode groups.
|
||||
SeriesID string
|
||||
Episodes []ReleaseEvent // ascending episode_key
|
||||
// Movie groups.
|
||||
ItemID string
|
||||
// Meta describes the series (episode groups) or the movie itself, with
|
||||
// Title already defaulted when the catalog row is missing.
|
||||
Meta ContentMeta
|
||||
}
|
||||
|
||||
// GroupContentEvents folds a batch of release events into display groups:
|
||||
// episodes group per (library, series) so a season pack renders as one line,
|
||||
// movies render individually but dedupe by item across libraries. Group order
|
||||
// follows first appearance in the batch (sweep order). metas is keyed by
|
||||
// series_id / item_id; missing entries fall back to generic labels.
|
||||
func GroupContentEvents(events []ReleaseEvent, metas map[string]ContentMeta) []ContentGroup {
|
||||
type groupKey struct {
|
||||
kind string
|
||||
libraryID int
|
||||
contentID string
|
||||
}
|
||||
index := make(map[groupKey]int)
|
||||
seenMovies := make(map[string]struct{})
|
||||
groups := make([]ContentGroup, 0, len(events))
|
||||
|
||||
for _, event := range events {
|
||||
switch normalizeEventKind(event.Kind) {
|
||||
case EventKindMovie:
|
||||
// The same movie landing in two libraries (e.g. "Movies" and
|
||||
// "Movies 4K") announces once.
|
||||
if _, dup := seenMovies[event.ItemID]; dup {
|
||||
continue
|
||||
}
|
||||
seenMovies[event.ItemID] = struct{}{}
|
||||
meta := metas[event.ItemID]
|
||||
if meta.Title == "" {
|
||||
meta.Title = "New movie"
|
||||
}
|
||||
groups = append(groups, ContentGroup{
|
||||
Kind: EventKindMovie,
|
||||
LibraryID: event.LibraryID,
|
||||
ItemID: event.ItemID,
|
||||
Meta: meta,
|
||||
})
|
||||
default:
|
||||
key := groupKey{EventKindEpisode, event.LibraryID, event.SeriesID}
|
||||
if at, ok := index[key]; ok {
|
||||
groups[at].Episodes = append(groups[at].Episodes, event)
|
||||
continue
|
||||
}
|
||||
meta := metas[event.SeriesID]
|
||||
if meta.Title == "" {
|
||||
meta.Title = genericEpisodeTitle
|
||||
}
|
||||
index[key] = len(groups)
|
||||
groups = append(groups, ContentGroup{
|
||||
Kind: EventKindEpisode,
|
||||
LibraryID: event.LibraryID,
|
||||
SeriesID: event.SeriesID,
|
||||
Episodes: []ReleaseEvent{event},
|
||||
Meta: meta,
|
||||
})
|
||||
}
|
||||
}
|
||||
for i := range groups {
|
||||
sort.Slice(groups[i].Episodes, func(a, b int) bool {
|
||||
return groups[i].Episodes[a].EpisodeKey < groups[i].Episodes[b].EpisodeKey
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// eventEpisodeCode renders one release event's "S2 E05" code.
|
||||
func eventEpisodeCode(event ReleaseEvent) string {
|
||||
return fmt.Sprintf("S%d E%d", event.SeasonNumber, event.EpisodeNumber)
|
||||
}
|
||||
|
||||
// episodeRangeLabel renders an episode group's span: "S2 E05" for one
|
||||
// episode, "S2 E01–E03" within a season, "S1 E10 – S2 E03" across seasons.
|
||||
func episodeRangeLabel(episodes []ReleaseEvent) string {
|
||||
if len(episodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
first, last := episodes[0], episodes[len(episodes)-1]
|
||||
switch {
|
||||
case len(episodes) == 1:
|
||||
return eventEpisodeCode(first)
|
||||
case first.SeasonNumber == last.SeasonNumber:
|
||||
return fmt.Sprintf("S%d E%d–E%d", first.SeasonNumber, first.EpisodeNumber, last.EpisodeNumber)
|
||||
default:
|
||||
return fmt.Sprintf("%s – %s", eventEpisodeCode(first), eventEpisodeCode(last))
|
||||
}
|
||||
}
|
||||
|
||||
// contentGroupTitle renders a group's display line.
|
||||
func contentGroupTitle(group ContentGroup) string {
|
||||
switch group.Kind {
|
||||
case EventKindMovie:
|
||||
return titleWithYear(group.Meta.Title, group.Meta.Year)
|
||||
default:
|
||||
if len(group.Episodes) == 1 {
|
||||
return fmt.Sprintf("%s — %s", group.Meta.Title, episodeRangeLabel(group.Episodes))
|
||||
}
|
||||
return fmt.Sprintf("%s — %d new episodes (%s)",
|
||||
group.Meta.Title, len(group.Episodes), episodeRangeLabel(group.Episodes))
|
||||
}
|
||||
}
|
||||
|
||||
// serverChannelMaxEmbeds caps content digests at Discord's per-message embed
|
||||
// limit; generic payloads share the cap so receivers see bounded bodies.
|
||||
const serverChannelMaxEmbeds = discordDMMaxEmbeds
|
||||
|
||||
// BuildServerChannelDiscordContent renders a content digest as a Discord
|
||||
// webhook body: one embed per group up to the 10-embed cap, the newest groups
|
||||
// kept, with an overflow line for the rest. Pure function.
|
||||
func BuildServerChannelDiscordContent(groups []ContentGroup, test bool) ([]byte, error) {
|
||||
overflow := 0
|
||||
if len(groups) > serverChannelMaxEmbeds {
|
||||
overflow = len(groups) - serverChannelMaxEmbeds
|
||||
groups = groups[len(groups)-serverChannelMaxEmbeds:]
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
embeds := make([]discordEmbed, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
author := "New episodes available on Silo"
|
||||
if group.Kind == EventKindMovie {
|
||||
author = "New movie available on Silo"
|
||||
} else if len(group.Episodes) == 1 {
|
||||
author = "New episode available on Silo"
|
||||
}
|
||||
ids := group.Meta.providerIDs()
|
||||
fields := make([]discordEmbedField, 0, 2)
|
||||
if rating := ratingLabel(group.Meta.RatingIMDB, group.Meta.RatingTMDB); rating != "" {
|
||||
fields = append(fields, discordEmbedField{Name: "Rating", Value: rating, Inline: true})
|
||||
}
|
||||
if genres := genresLabel(group.Meta.Genres); genres != "" {
|
||||
fields = append(fields, discordEmbedField{Name: "Genres", Value: genres, Inline: true})
|
||||
}
|
||||
embed := discordEmbed{
|
||||
Title: truncateWithEllipsis(contentGroupTitle(group), discordTitleLimit),
|
||||
URL: ids.titleURL(),
|
||||
Description: embedDescription(group.Meta.Overview, ids),
|
||||
Color: serverChannelColorContent,
|
||||
Author: &discordEmbedAuthor{Name: author},
|
||||
Footer: &discordEmbedFooter{Text: discordEmbedFooterText(group.Meta.ContentRating, test)},
|
||||
Timestamp: now,
|
||||
Fields: fields,
|
||||
}
|
||||
if group.Meta.PosterURL != "" {
|
||||
embed.Thumbnail = &discordEmbedMedia{URL: group.Meta.PosterURL}
|
||||
}
|
||||
enforceDiscordTotalLimit(&embed)
|
||||
embeds = append(embeds, embed)
|
||||
}
|
||||
body := discordWebhookBody{Embeds: embeds, Username: siloSenderName}
|
||||
if overflow > 0 {
|
||||
body.Content = fmt.Sprintf("…and %d more new items on Silo", overflow)
|
||||
}
|
||||
return json.Marshal(body)
|
||||
}
|
||||
|
||||
// serverChannelContentBody is the canonical generic-webhook JSON for a
|
||||
// content digest. Like the per-profile generic payload it carries no server
|
||||
// URL and no artwork URLs.
|
||||
type serverChannelContentBody struct {
|
||||
Event string `json:"event"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Version int `json:"version"`
|
||||
Test bool `json:"test"`
|
||||
Items []serverChannelContentRow `json:"items"`
|
||||
// Truncated reports how many additional groups were dropped by the
|
||||
// per-post item cap; 0 means the batch is complete.
|
||||
Truncated int `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
type serverChannelContentRow struct {
|
||||
Kind string `json:"kind"`
|
||||
LibraryID int `json:"library_id"`
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
SeriesTitle string `json:"series_title,omitempty"`
|
||||
// Episode span for episode groups.
|
||||
EpisodeCount int `json:"episode_count,omitempty"`
|
||||
FirstSeason int `json:"first_season,omitempty"`
|
||||
FirstEpisode int `json:"first_episode,omitempty"`
|
||||
LastSeason int `json:"last_season,omitempty"`
|
||||
LastEpisode int `json:"last_episode,omitempty"`
|
||||
EpisodeLabel string `json:"episode_label,omitempty"`
|
||||
}
|
||||
|
||||
// BuildServerChannelGenericContent renders a content digest as canonical Silo
|
||||
// JSON. Pure function.
|
||||
func BuildServerChannelGenericContent(groups []ContentGroup, channelID string, test bool) ([]byte, error) {
|
||||
truncated := 0
|
||||
if len(groups) > serverChannelMaxEmbeds {
|
||||
truncated = len(groups) - serverChannelMaxEmbeds
|
||||
groups = groups[len(groups)-serverChannelMaxEmbeds:]
|
||||
}
|
||||
items := make([]serverChannelContentRow, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
row := serverChannelContentRow{
|
||||
Kind: group.Kind,
|
||||
LibraryID: group.LibraryID,
|
||||
}
|
||||
switch group.Kind {
|
||||
case EventKindMovie:
|
||||
row.ItemID = group.ItemID
|
||||
row.Title = group.Meta.Title
|
||||
row.Year = group.Meta.Year
|
||||
default:
|
||||
row.SeriesID = group.SeriesID
|
||||
row.SeriesTitle = group.Meta.Title
|
||||
row.EpisodeCount = len(group.Episodes)
|
||||
if len(group.Episodes) > 0 {
|
||||
first := group.Episodes[0]
|
||||
last := group.Episodes[len(group.Episodes)-1]
|
||||
row.FirstSeason, row.FirstEpisode = first.SeasonNumber, first.EpisodeNumber
|
||||
row.LastSeason, row.LastEpisode = last.SeasonNumber, last.EpisodeNumber
|
||||
row.EpisodeLabel = episodeRangeLabel(group.Episodes)
|
||||
}
|
||||
}
|
||||
items = append(items, row)
|
||||
}
|
||||
return json.Marshal(serverChannelContentBody{
|
||||
Event: ServerChannelEventContentAdded,
|
||||
ChannelID: channelID,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Version: 1,
|
||||
Test: test,
|
||||
Items: items,
|
||||
Truncated: truncated,
|
||||
})
|
||||
}
|
||||
|
||||
// requestEventDescription maps a lifecycle event to its embed description.
|
||||
func requestEventDescription(event string) string {
|
||||
switch event {
|
||||
case ServerChannelEventRequestSubmitted:
|
||||
return "New media request on Silo"
|
||||
case ServerChannelEventRequestApproved:
|
||||
return "Media request approved"
|
||||
case ServerChannelEventRequestDeclined:
|
||||
return "Media request declined"
|
||||
case ServerChannelEventRequestFulfilled:
|
||||
return "Requested media is now available on Silo"
|
||||
default:
|
||||
return genericNotificationTitle
|
||||
}
|
||||
}
|
||||
|
||||
func requestEventColor(event string) int {
|
||||
switch event {
|
||||
case ServerChannelEventRequestSubmitted:
|
||||
return serverChannelColorSubmitted
|
||||
case ServerChannelEventRequestApproved:
|
||||
return serverChannelColorApproved
|
||||
case ServerChannelEventRequestDeclined:
|
||||
return serverChannelColorDeclined
|
||||
default:
|
||||
return serverChannelColorFulfilled
|
||||
}
|
||||
}
|
||||
|
||||
// BuildServerChannelRequestDiscord renders one request lifecycle event as a
|
||||
// Discord webhook body. Pure function.
|
||||
func BuildServerChannelRequestDiscord(event string, info RequestEventInfo) ([]byte, error) {
|
||||
title := info.Title
|
||||
if title == "" {
|
||||
title = "Media request"
|
||||
}
|
||||
title = titleWithYear(title, info.Year)
|
||||
ids := providerIDs{MediaType: info.MediaType, IMDB: info.IMDBID}
|
||||
if info.TMDBID > 0 {
|
||||
ids.TMDB = fmt.Sprintf("%d", info.TMDBID)
|
||||
}
|
||||
if info.TVDBID > 0 {
|
||||
ids.TVDB = fmt.Sprintf("%d", info.TVDBID)
|
||||
}
|
||||
fields := make([]discordEmbedField, 0, 2)
|
||||
if label := mediaTypeLabel(info.MediaType); label != "" {
|
||||
fields = append(fields, discordEmbedField{Name: "Type", Value: label, Inline: true})
|
||||
}
|
||||
if info.RequesterName != "" {
|
||||
fields = append(fields, discordEmbedField{
|
||||
Name: "Requested by",
|
||||
Value: truncateWithEllipsis(info.RequesterName, discordFieldValueLimit),
|
||||
Inline: true,
|
||||
})
|
||||
}
|
||||
embed := discordEmbed{
|
||||
Title: truncateWithEllipsis(title, discordTitleLimit),
|
||||
URL: ids.titleURL(),
|
||||
Description: embedDescription(info.Overview, ids),
|
||||
Color: requestEventColor(event),
|
||||
Author: &discordEmbedAuthor{Name: requestEventDescription(event)},
|
||||
Footer: &discordEmbedFooter{Text: siloSenderName},
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Fields: fields,
|
||||
}
|
||||
// Request poster paths are raw TMDB image paths from discovery, not
|
||||
// stored catalog artwork.
|
||||
if poster := tmdbRawImageURL(info.PosterPath); poster != "" {
|
||||
embed.Thumbnail = &discordEmbedMedia{URL: poster}
|
||||
}
|
||||
enforceDiscordTotalLimit(&embed)
|
||||
return json.Marshal(discordWebhookBody{Embeds: []discordEmbed{embed}, Username: siloSenderName})
|
||||
}
|
||||
|
||||
// serverChannelRequestBody is the canonical generic-webhook JSON for one
|
||||
// request lifecycle event.
|
||||
type serverChannelRequestBody struct {
|
||||
Event string `json:"event"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Version int `json:"version"`
|
||||
Test bool `json:"test"`
|
||||
Request serverChannelRequestPayload `json:"request"`
|
||||
}
|
||||
|
||||
type serverChannelRequestPayload struct {
|
||||
ID string `json:"id"`
|
||||
TMDBID int `json:"tmdb_id,omitempty"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
RequesterName string `json:"requester_name,omitempty"`
|
||||
}
|
||||
|
||||
// BuildServerChannelRequestGeneric renders one request lifecycle event as
|
||||
// canonical Silo JSON. Pure function.
|
||||
func BuildServerChannelRequestGeneric(event string, info RequestEventInfo, channelID string) ([]byte, error) {
|
||||
return json.Marshal(serverChannelRequestBody{
|
||||
Event: event,
|
||||
ChannelID: channelID,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Version: 1,
|
||||
Request: serverChannelRequestPayload{
|
||||
ID: info.RequestID,
|
||||
TMDBID: info.TMDBID,
|
||||
MediaType: info.MediaType,
|
||||
Title: info.Title,
|
||||
Year: info.Year,
|
||||
RequesterName: info.RequesterName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// mediaTypeLabel renders a request media type as a display label.
|
||||
func mediaTypeLabel(mediaType string) string {
|
||||
switch mediaType {
|
||||
case mediaTypeMovie:
|
||||
return "Movie"
|
||||
case mediaTypeSeries:
|
||||
return "Series"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// serverChannelHeaders builds the signed delivery headers for a generic
|
||||
// server-channel POST, mirroring the per-profile webhook convention
|
||||
// (X-Silo-Signature follows Stripe's t=...,v1=... form).
|
||||
func serverChannelHeaders(event, channelID, secret string, now time.Time, body []byte) map[string]string {
|
||||
timestamp := now.Unix()
|
||||
return map[string]string{
|
||||
"X-Silo-Event": event,
|
||||
"X-Silo-Channel-Id": channelID,
|
||||
"X-Silo-Timestamp": fmt.Sprintf("%d", timestamp),
|
||||
"X-Silo-Signature": SignGenericWebhook(secret, timestamp, body),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ServerChannelRepository owns notification_server_channels.
|
||||
type ServerChannelRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewServerChannelRepository creates a ServerChannelRepository.
|
||||
func NewServerChannelRepository(pool *pgxpool.Pool) *ServerChannelRepository {
|
||||
return &ServerChannelRepository{pool: pool}
|
||||
}
|
||||
|
||||
const serverChannelColumns = `
|
||||
id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled,
|
||||
notify_new_movies, notify_new_episodes,
|
||||
notify_request_submitted, notify_request_approved,
|
||||
notify_request_declined, notify_request_fulfilled,
|
||||
watermark_created_at, watermark_id,
|
||||
last_attempt_at, consecutive_failures, disabled_reason,
|
||||
last_success_at, last_failure_at, last_failure_status, last_failure_message,
|
||||
created_by_user_id, created_at, updated_at`
|
||||
|
||||
func scanServerChannel(row pgx.Row) (*ServerChannel, error) {
|
||||
var ch ServerChannel
|
||||
err := row.Scan(
|
||||
&ch.ID, &ch.Name, &ch.Type, &ch.URLCiphertext, &ch.URLHost,
|
||||
&ch.SigningSecretCiphertext, &ch.Enabled,
|
||||
&ch.NotifyNewMovies, &ch.NotifyNewEpisodes,
|
||||
&ch.NotifyRequestSubmitted, &ch.NotifyRequestApproved,
|
||||
&ch.NotifyRequestDeclined, &ch.NotifyRequestFulfilled,
|
||||
&ch.WatermarkCreatedAt, &ch.WatermarkID,
|
||||
&ch.LastAttemptAt, &ch.ConsecutiveFailures, &ch.DisabledReason,
|
||||
&ch.LastSuccessAt, &ch.LastFailureAt, &ch.LastFailureStatus, &ch.LastFailureMessage,
|
||||
&ch.CreatedByUserID, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ch, nil
|
||||
}
|
||||
|
||||
func scanServerChannels(rows pgx.Rows) ([]ServerChannel, error) {
|
||||
defer rows.Close()
|
||||
channels := make([]ServerChannel, 0, 4)
|
||||
for rows.Next() {
|
||||
ch, err := scanServerChannel(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan server channel: %w", err)
|
||||
}
|
||||
channels = append(channels, *ch)
|
||||
}
|
||||
return channels, rows.Err()
|
||||
}
|
||||
|
||||
// List returns every server channel for the admin listing endpoint.
|
||||
func (r *ServerChannelRepository) List(ctx context.Context) ([]ServerChannel, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT `+serverChannelColumns+` FROM notification_server_channels ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list server channels: %w", err)
|
||||
}
|
||||
return scanServerChannels(rows)
|
||||
}
|
||||
|
||||
// GetByID returns one server channel; (nil, nil) when absent.
|
||||
func (r *ServerChannelRepository) GetByID(ctx context.Context, id string) (*ServerChannel, error) {
|
||||
ch, err := scanServerChannel(r.pool.QueryRow(ctx,
|
||||
`SELECT `+serverChannelColumns+` FROM notification_server_channels WHERE id = $1`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get server channel: %w", err)
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ErrServerChannelNameTaken is returned when a channel with the requested
|
||||
// name already exists.
|
||||
var ErrServerChannelNameTaken = errors.New("a server channel with this name already exists")
|
||||
|
||||
func isServerChannelNameViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) &&
|
||||
pgErr.Code == "23505" &&
|
||||
pgErr.ConstraintName == "notification_server_channels_name_key"
|
||||
}
|
||||
|
||||
// InsertWithLimit persists a new channel unless the server is already at
|
||||
// maxChannels. The count and insert run under an advisory transaction lock so
|
||||
// concurrent creates cannot both pass the check.
|
||||
func (r *ServerChannelRepository) InsertWithLimit(ctx context.Context, ch ServerChannel, maxChannels int) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin server channel insert: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended('notification_server_channels', 0))`); err != nil {
|
||||
return fmt.Errorf("lock server channel quota: %w", err)
|
||||
}
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT count(*) FROM notification_server_channels`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count server channels: %w", err)
|
||||
}
|
||||
if count >= maxChannels {
|
||||
return ErrServerChannelLimit
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO notification_server_channels
|
||||
(id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled,
|
||||
notify_new_movies, notify_new_episodes,
|
||||
notify_request_submitted, notify_request_approved,
|
||||
notify_request_declined, notify_request_fulfilled,
|
||||
created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
||||
ch.ID, ch.Name, ch.Type, ch.URLCiphertext, ch.URLHost, ch.SigningSecretCiphertext, ch.Enabled,
|
||||
ch.NotifyNewMovies, ch.NotifyNewEpisodes,
|
||||
ch.NotifyRequestSubmitted, ch.NotifyRequestApproved,
|
||||
ch.NotifyRequestDeclined, ch.NotifyRequestFulfilled,
|
||||
ch.CreatedByUserID); err != nil {
|
||||
if isServerChannelNameViolation(err) {
|
||||
return ErrServerChannelNameTaken
|
||||
}
|
||||
return fmt.Errorf("insert server channel: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit server channel insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update persists the admin-mutable fields (name, URL, secret, enabled state,
|
||||
// event toggles) and bumps updated_at. Watermark and failure bookkeeping are
|
||||
// owned by the sweep/send paths; the service resets them explicitly through
|
||||
// ResetDispatchState on enable transitions.
|
||||
func (r *ServerChannelRepository) Update(ctx context.Context, ch ServerChannel) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE notification_server_channels SET
|
||||
name = $2, url_ciphertext = $3, url_host = $4,
|
||||
signing_secret_ciphertext = $5, enabled = $6,
|
||||
notify_new_movies = $7, notify_new_episodes = $8,
|
||||
notify_request_submitted = $9, notify_request_approved = $10,
|
||||
notify_request_declined = $11, notify_request_fulfilled = $12,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
ch.ID, ch.Name, ch.URLCiphertext, ch.URLHost,
|
||||
ch.SigningSecretCiphertext, ch.Enabled,
|
||||
ch.NotifyNewMovies, ch.NotifyNewEpisodes,
|
||||
ch.NotifyRequestSubmitted, ch.NotifyRequestApproved,
|
||||
ch.NotifyRequestDeclined, ch.NotifyRequestFulfilled)
|
||||
if err != nil {
|
||||
if isServerChannelNameViolation(err) {
|
||||
return ErrServerChannelNameTaken
|
||||
}
|
||||
return fmt.Errorf("update server channel: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetDispatchState clears failure backoff/auto-disable state and
|
||||
// fast-forwards the content watermark to now. Called when a channel is
|
||||
// (re-)enabled or its URL is replaced: a channel that was dead for days must
|
||||
// resume from the present, not replay the gap.
|
||||
func (r *ServerChannelRepository) ResetDispatchState(ctx context.Context, id string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE notification_server_channels SET
|
||||
consecutive_failures = 0, disabled_reason = NULL, last_attempt_at = NULL,
|
||||
watermark_created_at = now(), watermark_id = '',
|
||||
updated_at = now()
|
||||
WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset server channel dispatch state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a server channel. Idempotent.
|
||||
func (r *ServerChannelRepository) Delete(ctx context.Context, id string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM notification_server_channels WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListEnabledForContent returns enabled, non-auto-disabled channels with at
|
||||
// least one content kind toggled on, for the sweep pre-scan.
|
||||
func (r *ServerChannelRepository) ListEnabledForContent(ctx context.Context) ([]ServerChannel, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+serverChannelColumns+`
|
||||
FROM notification_server_channels
|
||||
WHERE enabled AND disabled_reason IS NULL
|
||||
AND (notify_new_movies OR notify_new_episodes)
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list content server channels: %w", err)
|
||||
}
|
||||
return scanServerChannels(rows)
|
||||
}
|
||||
|
||||
// ListEnabledForRequests returns enabled, non-auto-disabled channels with at
|
||||
// least one request lifecycle toggle on; the caller filters per event.
|
||||
func (r *ServerChannelRepository) ListEnabledForRequests(ctx context.Context) ([]ServerChannel, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+serverChannelColumns+`
|
||||
FROM notification_server_channels
|
||||
WHERE enabled AND disabled_reason IS NULL
|
||||
AND (notify_request_submitted OR notify_request_approved
|
||||
OR notify_request_declined OR notify_request_fulfilled)
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list request server channels: %w", err)
|
||||
}
|
||||
return scanServerChannels(rows)
|
||||
}
|
||||
|
||||
// ClaimForSweep locks one channel row for a sweep attempt with FOR UPDATE
|
||||
// SKIP LOCKED; (nil, nil) means another node holds it. Must run inside the
|
||||
// caller's transaction.
|
||||
func (r *ServerChannelRepository) ClaimForSweep(ctx context.Context, tx pgx.Tx, id string) (*ServerChannel, error) {
|
||||
ch, err := scanServerChannel(tx.QueryRow(ctx,
|
||||
`SELECT `+serverChannelColumns+`
|
||||
FROM notification_server_channels
|
||||
WHERE id = $1 AND enabled AND disabled_reason IS NULL
|
||||
FOR UPDATE SKIP LOCKED`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim server channel: %w", err)
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// MarkSwept advances the content watermark past everything the sweep covered
|
||||
// and resets failure backoff. Must run inside the claim transaction.
|
||||
func (r *ServerChannelRepository) MarkSwept(ctx context.Context, tx pgx.Tx, id string, watermark Cursor) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE notification_server_channels SET
|
||||
watermark_created_at = $2, watermark_id = $3,
|
||||
last_attempt_at = now(), last_success_at = now(),
|
||||
consecutive_failures = 0,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, watermark.CreatedAt, watermark.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark server channel swept: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverChannelMaxConsecutiveFailures auto-disables a channel after this many
|
||||
// consecutive failed sends (sweep and request paths share the counter). With
|
||||
// the sweep's exponential backoff capped at 6h, 20 failures spans multiple
|
||||
// days of a consistently dead destination.
|
||||
const serverChannelMaxConsecutiveFailures = 20
|
||||
|
||||
// serverChannelAutoDisableReason is the admin-visible auto-disable text.
|
||||
const serverChannelAutoDisableReason = "Deliveries failed repeatedly; check the destination and re-enable the channel"
|
||||
|
||||
// recordFailure increments the failure streak, stores the diagnostic, and
|
||||
// auto-disables the channel when the streak crosses the threshold. The
|
||||
// watermark stays put so the next eligible pass retries the same events.
|
||||
func serverChannelRecordFailure(ctx context.Context, q interface {
|
||||
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
|
||||
}, id string, httpStatus *int, message string,
|
||||
) error {
|
||||
_, err := q.Exec(ctx, `
|
||||
UPDATE notification_server_channels SET
|
||||
last_attempt_at = now(), last_failure_at = now(),
|
||||
last_failure_status = $2,
|
||||
last_failure_message = left($3, 256),
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
disabled_reason = CASE
|
||||
WHEN consecutive_failures + 1 >= $4 THEN left($5, 256)
|
||||
ELSE disabled_reason
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, httpStatus, message,
|
||||
serverChannelMaxConsecutiveFailures, serverChannelAutoDisableReason)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record server channel failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkSweepFailure records a failed sweep send inside the claim transaction.
|
||||
func (r *ServerChannelRepository) MarkSweepFailure(ctx context.Context, tx pgx.Tx, id string, httpStatus *int, message string) error {
|
||||
return serverChannelRecordFailure(ctx, tx, id, httpStatus, message)
|
||||
}
|
||||
|
||||
// RecordSendSuccess resets the failure streak after a successful request-path
|
||||
// send (the watermark is content-sweep state and is not touched).
|
||||
func (r *ServerChannelRepository) RecordSendSuccess(ctx context.Context, id string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE notification_server_channels SET
|
||||
last_attempt_at = now(), last_success_at = now(),
|
||||
consecutive_failures = 0,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record server channel success: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSendFailure records a failed request-path send.
|
||||
func (r *ServerChannelRepository) RecordSendFailure(ctx context.Context, id string, httpStatus *int, message string) error {
|
||||
return serverChannelRecordFailure(ctx, r.pool, id, httpStatus, message)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
// serverChannelRequestPostTimeout bounds one detached lifecycle post fan-out
|
||||
// (up to serverChannelMaxChannels sequential sends at 10s each is the
|
||||
// theoretical worst case; in practice a couple of channels are subscribed).
|
||||
const serverChannelRequestPostTimeout = 60 * time.Second
|
||||
|
||||
// ServerChannelLifecycleNotifier adapts request lifecycle transitions to
|
||||
// server-channel posts. Sends are detached and best-effort: the request flow
|
||||
// never waits on or fails because of a broadcast destination.
|
||||
type ServerChannelLifecycleNotifier struct {
|
||||
system *System
|
||||
}
|
||||
|
||||
// NewServerChannelLifecycleNotifier creates the adapter; returns nil when the
|
||||
// system has no server-channel support (no at-rest cipher).
|
||||
func NewServerChannelLifecycleNotifier(system *System) *ServerChannelLifecycleNotifier {
|
||||
if system == nil || system.serverChannelWorker == nil {
|
||||
return nil
|
||||
}
|
||||
return &ServerChannelLifecycleNotifier{system: system}
|
||||
}
|
||||
|
||||
// RequestSubmitted implements requests.LifecycleNotifier.
|
||||
func (n *ServerChannelLifecycleNotifier) RequestSubmitted(ctx context.Context, req requests.Request) {
|
||||
n.post(ctx, ServerChannelEventRequestSubmitted, req)
|
||||
}
|
||||
|
||||
// RequestApproved implements requests.LifecycleNotifier.
|
||||
func (n *ServerChannelLifecycleNotifier) RequestApproved(ctx context.Context, req requests.Request) {
|
||||
n.post(ctx, ServerChannelEventRequestApproved, req)
|
||||
}
|
||||
|
||||
// RequestDeclined implements requests.LifecycleNotifier.
|
||||
func (n *ServerChannelLifecycleNotifier) RequestDeclined(ctx context.Context, req requests.Request) {
|
||||
n.post(ctx, ServerChannelEventRequestDeclined, req)
|
||||
}
|
||||
|
||||
func (n *ServerChannelLifecycleNotifier) post(ctx context.Context, event string, req requests.Request) {
|
||||
if n == nil || n.system == nil {
|
||||
return
|
||||
}
|
||||
n.system.PostServerChannelRequestEvent(ctx, event, requestEventInfoFor(req))
|
||||
}
|
||||
|
||||
// requestEventInfoFor converts a request into the payload-layer shape.
|
||||
func requestEventInfoFor(req requests.Request) RequestEventInfo {
|
||||
info := RequestEventInfo{
|
||||
RequestID: req.ID,
|
||||
TMDBID: req.TMDBID,
|
||||
IMDBID: req.IMDbID,
|
||||
MediaType: string(req.MediaType),
|
||||
Title: req.Title,
|
||||
Overview: req.Overview,
|
||||
PosterPath: req.PosterPath,
|
||||
RequesterName: req.RequesterUsername,
|
||||
}
|
||||
if req.Year != nil {
|
||||
info.Year = *req.Year
|
||||
}
|
||||
if req.TVDBID != nil {
|
||||
info.TVDBID = *req.TVDBID
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// PostServerChannelRequestEvent posts one request lifecycle event to opted-in
|
||||
// server channels on a detached goroutine. No-op when server channels are not
|
||||
// configured. Best-effort: the caller's flow never blocks on it.
|
||||
func (s *System) PostServerChannelRequestEvent(ctx context.Context, event string, info RequestEventInfo) {
|
||||
if s == nil || s.serverChannelWorker == nil {
|
||||
return
|
||||
}
|
||||
// The caller's context ends with its HTTP request or reconcile pass;
|
||||
// posting continues on its own deadline (the detector's detach pattern).
|
||||
postCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), serverChannelRequestPostTimeout)
|
||||
go func() {
|
||||
defer cancel()
|
||||
s.serverChannelWorker.PostRequestEvent(postCtx, event, info)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/secret"
|
||||
)
|
||||
|
||||
// serverChannelSender owns the transport for server-channel posts: ciphertext
|
||||
// handling and the guarded HTTP client. It is shared by the sweep worker, the
|
||||
// request-event path, and admin test sends.
|
||||
type serverChannelSender struct {
|
||||
cipher *secret.Cipher
|
||||
settings *Settings
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newServerChannelSender(cipher *secret.Cipher, settings *Settings) *serverChannelSender {
|
||||
return &serverChannelSender{
|
||||
cipher: cipher,
|
||||
settings: settings,
|
||||
client: newWebhookHTTPClient(func() bool {
|
||||
return settings.WebhooksAllowPrivateDestinations(context.Background())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serverChannelSender) decryptURL(ch *ServerChannel) (string, error) {
|
||||
return s.cipher.Decrypt(ch.URLCiphertext, serverChannelURLAAD(ch.ID))
|
||||
}
|
||||
|
||||
func (s *serverChannelSender) decryptSecret(ch *ServerChannel) (string, error) {
|
||||
if ch.SigningSecretCiphertext == nil {
|
||||
return "", fmt.Errorf("server channel has no signing secret")
|
||||
}
|
||||
return s.cipher.Decrypt(*ch.SigningSecretCiphertext, serverChannelSecretAAD(ch.ID))
|
||||
}
|
||||
|
||||
// buildPayload renders the type-specific body and headers for one post:
|
||||
// Discord bodies go unsigned, generic bodies get the signed Silo headers.
|
||||
func (s *serverChannelSender) buildPayload(ch *ServerChannel, event string, buildDiscord, buildGeneric func() ([]byte, error)) (body []byte, headers map[string]string, err error) {
|
||||
switch ch.Type {
|
||||
case WebhookTypeDiscord:
|
||||
body, err = buildDiscord()
|
||||
return body, nil, err
|
||||
case WebhookTypeGeneric:
|
||||
body, err = buildGeneric()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
signingSecret, err := s.decryptSecret(ch)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return body, serverChannelHeaders(event, ch.ID, signingSecret, time.Now(), body), nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unknown server channel type %q", ch.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// buildContent renders the type-specific body and headers for a content
|
||||
// digest post.
|
||||
func (s *serverChannelSender) buildContent(ch *ServerChannel, groups []ContentGroup, test bool) ([]byte, map[string]string, error) {
|
||||
return s.buildPayload(ch, ServerChannelEventContentAdded,
|
||||
func() ([]byte, error) { return BuildServerChannelDiscordContent(groups, test) },
|
||||
func() ([]byte, error) { return BuildServerChannelGenericContent(groups, ch.ID, test) })
|
||||
}
|
||||
|
||||
// buildRequest renders the type-specific body and headers for one request
|
||||
// lifecycle post.
|
||||
func (s *serverChannelSender) buildRequest(ch *ServerChannel, event string, info RequestEventInfo) ([]byte, map[string]string, error) {
|
||||
return s.buildPayload(ch, event,
|
||||
func() ([]byte, error) { return BuildServerChannelRequestDiscord(event, info) },
|
||||
func() ([]byte, error) { return BuildServerChannelRequestGeneric(event, info, ch.ID) })
|
||||
}
|
||||
|
||||
// post decrypts the destination and POSTs one prepared payload.
|
||||
func (s *serverChannelSender) post(ctx context.Context, ch *ServerChannel, body []byte, headers map[string]string) webhookSendResult {
|
||||
url, err := s.decryptURL(ch)
|
||||
if err != nil {
|
||||
return webhookSendResult{Message: "channel URL could not be decrypted"}
|
||||
}
|
||||
return sendWebhook(ctx, s.client, url, body, headers)
|
||||
}
|
||||
|
||||
// sendContent renders and posts a content digest.
|
||||
func (s *serverChannelSender) sendContent(ctx context.Context, ch *ServerChannel, groups []ContentGroup, test bool) webhookSendResult {
|
||||
body, headers, err := s.buildContent(ch, groups, test)
|
||||
if err != nil {
|
||||
return webhookSendResult{Message: "payload build failed"}
|
||||
}
|
||||
return s.post(ctx, ch, body, headers)
|
||||
}
|
||||
|
||||
// sendRequest renders and posts one request lifecycle event.
|
||||
func (s *serverChannelSender) sendRequest(ctx context.Context, ch *ServerChannel, event string, info RequestEventInfo) webhookSendResult {
|
||||
body, headers, err := s.buildRequest(ch, event, info)
|
||||
if err != nil {
|
||||
return webhookSendResult{Message: "payload build failed"}
|
||||
}
|
||||
return s.post(ctx, ch, body, headers)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/secret"
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
// Server channel service errors surfaced to the API layer.
|
||||
var (
|
||||
ErrServerChannelInvalid = errors.New("invalid server channel")
|
||||
ErrServerChannelNotFound = errors.New("server channel not found")
|
||||
ErrServerChannelLimit = errors.New("server channel limit reached")
|
||||
ErrServerChannelsDisabled = errors.New("server channels are disabled")
|
||||
)
|
||||
|
||||
// serverChannelMaxChannels caps how many broadcast destinations a server can
|
||||
// have. Far above any realistic deployment; exists so a scripted runaway
|
||||
// cannot fill the table.
|
||||
const serverChannelMaxChannels = 20
|
||||
|
||||
// ServerChannelService owns admin CRUD, validation, and signing-secret
|
||||
// handling for server channels. It mirrors WebhookService: URLs and secrets
|
||||
// are encrypted at rest, bound to the row identity, and never returned after
|
||||
// creation.
|
||||
type ServerChannelService struct {
|
||||
repo *ServerChannelRepository
|
||||
cipher *secret.Cipher
|
||||
settings *Settings
|
||||
sender *serverChannelSender
|
||||
}
|
||||
|
||||
func newServerChannelService(repo *ServerChannelRepository, cipher *secret.Cipher, settings *Settings, sender *serverChannelSender) *ServerChannelService {
|
||||
return &ServerChannelService{repo: repo, cipher: cipher, settings: settings, sender: sender}
|
||||
}
|
||||
|
||||
// ServerChannelInput is the create/update request shape. Pointer fields are
|
||||
// optional on update; Create requires Name and URL.
|
||||
type ServerChannelInput struct {
|
||||
Name *string
|
||||
URL *string
|
||||
Type *string
|
||||
Enabled *bool
|
||||
NotifyNewMovies *bool
|
||||
NotifyNewEpisodes *bool
|
||||
NotifyRequestSubmitted *bool
|
||||
NotifyRequestApproved *bool
|
||||
NotifyRequestDeclined *bool
|
||||
NotifyRequestFulfilled *bool
|
||||
}
|
||||
|
||||
// List returns every server channel (ciphertext fields are for internal use;
|
||||
// the handler view must expose url_host only).
|
||||
func (s *ServerChannelService) List(ctx context.Context) ([]ServerChannel, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
// Create validates and persists a new server channel. For generic channels
|
||||
// the returned signingSecret is shown exactly once.
|
||||
func (s *ServerChannelService) Create(ctx context.Context, createdByUserID int, input ServerChannelInput) (*ServerChannel, string, error) {
|
||||
// The kill switch blocks new destinations; existing channels stay
|
||||
// manageable (list/update/delete) so a later disable never strands rows.
|
||||
if !s.settings.ServerChannelsEnabled(ctx) {
|
||||
return nil, "", ErrServerChannelsDisabled
|
||||
}
|
||||
if input.Name == nil || input.URL == nil {
|
||||
return nil, "", fmt.Errorf("%w: name and url are required", ErrServerChannelInvalid)
|
||||
}
|
||||
name, err := validateChannelName(*input.Name, ErrServerChannelInvalid)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
rawURL := strings.TrimSpace(*input.URL)
|
||||
host, err := ValidateWebhookURL(rawURL, s.settings.WebhooksAllowPrivateDestinations(ctx))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrServerChannelInvalid, err.Error())
|
||||
}
|
||||
|
||||
channelType := ""
|
||||
if input.Type != nil {
|
||||
channelType = strings.TrimSpace(*input.Type)
|
||||
}
|
||||
channelType, err = resolveWebhookType(rawURL, channelType, ErrServerChannelInvalid)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
ch := ServerChannel{
|
||||
ID: ulid.Make().String(),
|
||||
Name: name,
|
||||
Type: channelType,
|
||||
URLHost: host,
|
||||
Enabled: boolOrDefault(input.Enabled, true),
|
||||
NotifyNewMovies: boolOrDefault(input.NotifyNewMovies, true),
|
||||
NotifyNewEpisodes: boolOrDefault(input.NotifyNewEpisodes, true),
|
||||
NotifyRequestSubmitted: boolOrDefault(input.NotifyRequestSubmitted, false),
|
||||
NotifyRequestApproved: boolOrDefault(input.NotifyRequestApproved, false),
|
||||
NotifyRequestDeclined: boolOrDefault(input.NotifyRequestDeclined, false),
|
||||
NotifyRequestFulfilled: boolOrDefault(input.NotifyRequestFulfilled, false),
|
||||
CreatedByUserID: createdByUserID,
|
||||
}
|
||||
ch.URLCiphertext, err = s.cipher.Encrypt(rawURL, serverChannelURLAAD(ch.ID))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("encrypt server channel url: %w", err)
|
||||
}
|
||||
|
||||
signingSecret := ""
|
||||
if channelType == WebhookTypeGeneric {
|
||||
signingSecret, err = newSigningSecret()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ciphertext, err := s.cipher.Encrypt(signingSecret, serverChannelSecretAAD(ch.ID))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("encrypt signing secret: %w", err)
|
||||
}
|
||||
ch.SigningSecretCiphertext = &ciphertext
|
||||
}
|
||||
|
||||
if err := s.repo.InsertWithLimit(ctx, ch, serverChannelMaxChannels); err != nil {
|
||||
if errors.Is(err, ErrServerChannelNameTaken) {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrServerChannelInvalid, err.Error())
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
return &ch, signingSecret, nil
|
||||
}
|
||||
|
||||
// Update applies the provided fields. A URL change re-validates the
|
||||
// destination; URL changes and enable transitions reset the failure streak
|
||||
// and fast-forward the content watermark to now (a long-dead channel must
|
||||
// resume from the present, not replay the gap).
|
||||
func (s *ServerChannelService) Update(ctx context.Context, id string, input ServerChannelInput) (*ServerChannel, error) {
|
||||
ch, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ch == nil {
|
||||
return nil, ErrServerChannelNotFound
|
||||
}
|
||||
wasDelivering := ch.Enabled && ch.DisabledReason == nil
|
||||
|
||||
if input.Name != nil {
|
||||
name, err := validateChannelName(*input.Name, ErrServerChannelInvalid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch.Name = name
|
||||
}
|
||||
resetDispatch := false
|
||||
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", ErrServerChannelInvalid, err.Error())
|
||||
}
|
||||
if err := validateReplacementURL(ch.Type, rawURL, ErrServerChannelInvalid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch.URLCiphertext, err = s.cipher.Encrypt(rawURL, serverChannelURLAAD(ch.ID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt server channel url: %w", err)
|
||||
}
|
||||
ch.URLHost = host
|
||||
resetDispatch = true
|
||||
}
|
||||
if input.Enabled != nil {
|
||||
ch.Enabled = *input.Enabled
|
||||
}
|
||||
if input.NotifyNewMovies != nil {
|
||||
ch.NotifyNewMovies = *input.NotifyNewMovies
|
||||
}
|
||||
if input.NotifyNewEpisodes != nil {
|
||||
ch.NotifyNewEpisodes = *input.NotifyNewEpisodes
|
||||
}
|
||||
if input.NotifyRequestSubmitted != nil {
|
||||
ch.NotifyRequestSubmitted = *input.NotifyRequestSubmitted
|
||||
}
|
||||
if input.NotifyRequestApproved != nil {
|
||||
ch.NotifyRequestApproved = *input.NotifyRequestApproved
|
||||
}
|
||||
if input.NotifyRequestDeclined != nil {
|
||||
ch.NotifyRequestDeclined = *input.NotifyRequestDeclined
|
||||
}
|
||||
if input.NotifyRequestFulfilled != nil {
|
||||
ch.NotifyRequestFulfilled = *input.NotifyRequestFulfilled
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, *ch); err != nil {
|
||||
if errors.Is(err, ErrServerChannelNameTaken) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrServerChannelInvalid, err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reset after the update commits: an enable transition (off→on or
|
||||
// auto-disabled→re-enabled) or a replacement URL clears the streak and
|
||||
// moves the watermark to now.
|
||||
nowDelivering := ch.Enabled
|
||||
if resetDispatch || (nowDelivering && !wasDelivering) {
|
||||
if err := s.repo.ResetDispatchState(ctx, ch.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch.ConsecutiveFailures = 0
|
||||
ch.DisabledReason = nil
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Delete removes a server channel. Idempotent.
|
||||
func (s *ServerChannelService) Delete(ctx context.Context, id string) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// RotateSecret generates and stores a new signing secret for a generic
|
||||
// channel, returning it exactly once.
|
||||
func (s *ServerChannelService) RotateSecret(ctx context.Context, id string) (string, error) {
|
||||
ch, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ch == nil {
|
||||
return "", ErrServerChannelNotFound
|
||||
}
|
||||
if ch.Type != WebhookTypeGeneric {
|
||||
return "", fmt.Errorf("%w: only generic channels have signing secrets", ErrServerChannelInvalid)
|
||||
}
|
||||
signingSecret, err := newSigningSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext, err := s.cipher.Encrypt(signingSecret, serverChannelSecretAAD(ch.ID))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt signing secret: %w", err)
|
||||
}
|
||||
ch.SigningSecretCiphertext = &ciphertext
|
||||
if err := s.repo.Update(ctx, *ch); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return signingSecret, nil
|
||||
}
|
||||
|
||||
// Test synchronously POSTs a clearly marked sample content digest. Test sends
|
||||
// never touch the watermark or failure counters.
|
||||
func (s *ServerChannelService) Test(ctx context.Context, id string) (*WebhookTestResult, error) {
|
||||
if !s.settings.ServerChannelsEnabled(ctx) {
|
||||
return nil, ErrServerChannelsDisabled
|
||||
}
|
||||
ch, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ch == nil {
|
||||
return nil, ErrServerChannelNotFound
|
||||
}
|
||||
return s.sender.sendContent(ctx, ch, sampleContentGroups(), true).testResult(), nil
|
||||
}
|
||||
|
||||
// sampleContentGroups is the fixture used for test sends.
|
||||
func sampleContentGroups() []ContentGroup {
|
||||
return []ContentGroup{
|
||||
{
|
||||
Kind: EventKindMovie,
|
||||
LibraryID: 1,
|
||||
ItemID: "test-movie",
|
||||
Meta: ContentMeta{Title: "Silo Test Movie", Year: 2026},
|
||||
},
|
||||
{
|
||||
Kind: EventKindEpisode,
|
||||
LibraryID: 1,
|
||||
SeriesID: "test-series",
|
||||
Meta: ContentMeta{Title: "Silo Test Series"},
|
||||
Episodes: []ReleaseEvent{
|
||||
{Kind: EventKindEpisode, LibraryID: 1, SeriesID: "test-series",
|
||||
SeasonNumber: 1, EpisodeNumber: 1, EpisodeKey: EpisodeKey(1, 1)},
|
||||
{Kind: EventKindEpisode, LibraryID: 1, SeriesID: "test-series",
|
||||
SeasonNumber: 1, EpisodeNumber: 2, EpisodeKey: EpisodeKey(1, 2)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server channel request lifecycle events. Fulfilled deliberately shares the
|
||||
// request.fulfilled delivery-type string so payload consumers see one
|
||||
// vocabulary across the per-profile and server-channel paths.
|
||||
const (
|
||||
ServerChannelEventRequestSubmitted = "request.submitted"
|
||||
ServerChannelEventRequestApproved = "request.approved"
|
||||
ServerChannelEventRequestDeclined = "request.declined"
|
||||
ServerChannelEventRequestFulfilled = DeliveryTypeRequestFulfilled
|
||||
)
|
||||
|
||||
// ServerChannelEventContentAdded is the generic-webhook event name for the
|
||||
// grouped new-content digest posts.
|
||||
const ServerChannelEventContentAdded = "content.added"
|
||||
|
||||
// ServerChannel is an admin-owned broadcast destination ("community
|
||||
// channel"): a Discord or generic webhook fed straight from release_events by
|
||||
// a per-channel watermark sweep plus best-effort request lifecycle posts. URL
|
||||
// and signing secret are stored as enc:v1: envelopes and never leave the
|
||||
// server.
|
||||
type ServerChannel struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
URLCiphertext string
|
||||
URLHost string
|
||||
SigningSecretCiphertext *string
|
||||
Enabled bool
|
||||
NotifyNewMovies bool
|
||||
NotifyNewEpisodes bool
|
||||
NotifyRequestSubmitted bool
|
||||
NotifyRequestApproved bool
|
||||
NotifyRequestDeclined bool
|
||||
NotifyRequestFulfilled bool
|
||||
WatermarkCreatedAt time.Time
|
||||
WatermarkID string
|
||||
LastAttemptAt *time.Time
|
||||
ConsecutiveFailures int
|
||||
DisabledReason *string
|
||||
LastSuccessAt *time.Time
|
||||
LastFailureAt *time.Time
|
||||
LastFailureStatus *int
|
||||
LastFailureMessage *string
|
||||
CreatedByUserID int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// AAD strings bind ciphertexts to this row and column. The prefix must stay
|
||||
// distinct from the profile-webhook namespace ("notification_webhook:") so
|
||||
// envelopes can never decrypt across tables.
|
||||
func serverChannelURLAAD(id string) string { return "server_channel:" + id + ":url" }
|
||||
func serverChannelSecretAAD(id string) string { return "server_channel:" + id + ":signing_secret" }
|
||||
|
||||
// WantsContentKind reports whether the channel's toggles include the given
|
||||
// release event kind. Unknown kinds (added by future versions) are skipped:
|
||||
// an old node must not announce content it cannot render.
|
||||
func (c ServerChannel) WantsContentKind(kind string) bool {
|
||||
switch normalizeEventKind(kind) {
|
||||
case EventKindEpisode:
|
||||
return c.NotifyNewEpisodes
|
||||
case EventKindMovie:
|
||||
return c.NotifyNewMovies
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// WantsRequestEvent reports whether the channel's toggles include the given
|
||||
// request lifecycle event.
|
||||
func (c ServerChannel) WantsRequestEvent(event string) bool {
|
||||
switch event {
|
||||
case ServerChannelEventRequestSubmitted:
|
||||
return c.NotifyRequestSubmitted
|
||||
case ServerChannelEventRequestApproved:
|
||||
return c.NotifyRequestApproved
|
||||
case ServerChannelEventRequestDeclined:
|
||||
return c.NotifyRequestDeclined
|
||||
case ServerChannelEventRequestFulfilled:
|
||||
return c.NotifyRequestFulfilled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RequestEventInfo carries the request fields server-channel payloads render.
|
||||
// It is a local shape so the payload layer does not import internal/requests.
|
||||
type RequestEventInfo struct {
|
||||
RequestID string
|
||||
TMDBID int
|
||||
TVDBID int
|
||||
IMDBID string
|
||||
MediaType string // "movie" | "series"
|
||||
Title string
|
||||
Year int
|
||||
Overview string
|
||||
PosterPath string // raw TMDB image path ("/abc.jpg") from discovery
|
||||
RequesterName string
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// serverChannelFetchLimit bounds one sweep batch. Renderers cap how many
|
||||
// groups one post shows; a larger backlog drains across consecutive passes.
|
||||
const serverChannelFetchLimit = 200
|
||||
|
||||
// serverChannelWorker sweeps release_events into per-channel content digest
|
||||
// posts. Unlike the per-profile channels it reads the event stream directly —
|
||||
// there is no profile fan-out for broadcast destinations — using a
|
||||
// per-channel (created_at, id) watermark that advances only on success.
|
||||
// Multi-node safe: the channel row claim uses FOR UPDATE SKIP LOCKED.
|
||||
type serverChannelWorker struct {
|
||||
pool *pgxpool.Pool
|
||||
repo *ServerChannelRepository
|
||||
releases *ReleaseRepository
|
||||
sender *serverChannelSender
|
||||
settings *Settings
|
||||
logger *slog.Logger
|
||||
nudge chan struct{}
|
||||
// posterURL picks the artwork URL Discord embeds may carry. Wired by
|
||||
// NewSystem after construction; nil renders embeds without images.
|
||||
posterURL func(ctx context.Context, posterPath, posterSourcePath string) string
|
||||
|
||||
// Short-lived cache behind requestEventChannels.
|
||||
requestChannelsMu sync.Mutex
|
||||
requestChannels []ServerChannel
|
||||
requestChannelsFetchedAt time.Time
|
||||
}
|
||||
|
||||
const requestChannelCacheTTL = 15 * time.Second
|
||||
|
||||
func newServerChannelWorker(
|
||||
pool *pgxpool.Pool,
|
||||
repo *ServerChannelRepository,
|
||||
releases *ReleaseRepository,
|
||||
sender *serverChannelSender,
|
||||
settings *Settings,
|
||||
) *serverChannelWorker {
|
||||
return &serverChannelWorker{
|
||||
pool: pool,
|
||||
repo: repo,
|
||||
releases: releases,
|
||||
sender: sender,
|
||||
settings: settings,
|
||||
logger: slog.Default().With("component", "notifications.server_channels"),
|
||||
nudge: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Nudge schedules a near-term pass. Events younger than the batch window are
|
||||
// invisible to the sweep regardless, so a nudge mostly helps after the window
|
||||
// has already elapsed (e.g. a settings flip). Non-blocking.
|
||||
func (w *serverChannelWorker) Nudge() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.nudge <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run sweeps eligible channels until ctx is canceled.
|
||||
func (w *serverChannelWorker) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(channelPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-w.nudge:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(channelNudgeDelay):
|
||||
}
|
||||
}
|
||||
if !w.settings.ServerChannelsEnabled(ctx) {
|
||||
continue
|
||||
}
|
||||
w.runPass(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// runPass attempts one sweep per eligible channel. Failures back off per
|
||||
// channel via the shared exponential-backoff rule.
|
||||
func (w *serverChannelWorker) runPass(ctx context.Context) {
|
||||
channels, err := w.repo.ListEnabledForContent(ctx)
|
||||
if err != nil {
|
||||
w.logger.Error("server channel pass: list channels failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return
|
||||
}
|
||||
batchAge := w.settings.ServerChannelsBatchWindow(ctx)
|
||||
now := time.Now()
|
||||
|
||||
failures := 0
|
||||
for _, ch := range channels {
|
||||
if ctx.Err() != nil || failures >= channelMaxFailuresPerPass {
|
||||
return
|
||||
}
|
||||
if !channelRetryEligible(now, ch.LastAttemptAt, ch.ConsecutiveFailures) {
|
||||
continue
|
||||
}
|
||||
// Cheap pre-check so idle channels don't open a claim transaction
|
||||
// every pass. A stale watermark only ever costs a harmless extra
|
||||
// claim.
|
||||
pending, err := w.releases.HasEventsSince(ctx,
|
||||
Cursor{CreatedAt: ch.WatermarkCreatedAt, ID: ch.WatermarkID}, batchAge)
|
||||
if err != nil {
|
||||
w.logger.Warn("server channel pass: pending check failed", "channel_id", ch.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if !pending {
|
||||
continue
|
||||
}
|
||||
sent, err := w.processChannel(ctx, ch.ID, batchAge)
|
||||
if err != nil {
|
||||
failures++
|
||||
w.logger.Warn("server channel sweep failed", "channel_id", ch.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if sent {
|
||||
// Drain a large backlog promptly instead of waiting a poll cycle.
|
||||
w.Nudge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processChannel sweeps one channel under its row lock: read events past the
|
||||
// watermark, group, send, and advance the watermark — all in one transaction
|
||||
// so the watermark commits only with the outcome it describes. Returns
|
||||
// whether a post went out.
|
||||
func (w *serverChannelWorker) processChannel(ctx context.Context, channelID string, batchAge time.Duration) (bool, error) {
|
||||
tx, err := w.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("begin server channel sweep tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
ch, err := w.repo.ClaimForSweep(ctx, tx, channelID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ch == nil {
|
||||
return false, nil // another node holds the row, or the channel was just disabled
|
||||
}
|
||||
// Re-check the kill switch under the lock: disabling the feature must
|
||||
// stop in-flight sweeps, not just future passes.
|
||||
if !w.settings.ServerChannelsEnabled(ctx) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
since := Cursor{CreatedAt: ch.WatermarkCreatedAt, ID: ch.WatermarkID}
|
||||
events, err := w.releases.ListEventsSince(ctx, tx, since, batchAge, serverChannelFetchLimit)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// The watermark passes everything fetched — including events filtered by
|
||||
// the channel's kind toggles and events past the staleness horizon — so
|
||||
// skipped events are never re-read. It only ever moves forward.
|
||||
last := events[len(events)-1]
|
||||
watermark := maxCursor(Cursor{CreatedAt: last.CreatedAt, ID: last.ID}, since)
|
||||
|
||||
staleCutoff := time.Now().Add(-w.settings.MaxEventAge(ctx))
|
||||
fresh := make([]ReleaseEvent, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event.CreatedAt.Before(staleCutoff) {
|
||||
continue // same staleness policy as fanout: old news is noise
|
||||
}
|
||||
if !ch.WantsContentKind(event.Kind) {
|
||||
continue
|
||||
}
|
||||
fresh = append(fresh, event)
|
||||
}
|
||||
if len(fresh) == 0 {
|
||||
if err := w.repo.MarkSwept(ctx, tx, ch.ID, watermark); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
metas, err := loadContentMeta(ctx, tx, fresh)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
groups := GroupContentEvents(fresh, metas)
|
||||
if ch.Type == WebhookTypeDiscord && w.posterURL != nil {
|
||||
for i := range groups {
|
||||
groups[i].Meta.PosterURL = w.posterURL(ctx,
|
||||
groups[i].Meta.PosterPath, groups[i].Meta.PosterSourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
result := w.sender.sendContent(ctx, ch, groups, false)
|
||||
if !result.OK {
|
||||
var status *int
|
||||
if result.HTTPStatus > 0 {
|
||||
status = &result.HTTPStatus
|
||||
}
|
||||
if err := w.repo.MarkSweepFailure(ctx, tx, ch.ID, status, result.Message); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, fmt.Errorf("send failed: %s", result.Message)
|
||||
}
|
||||
|
||||
if err := w.repo.MarkSwept(ctx, tx, ch.ID, watermark); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
w.logger.Info("server channel content posted",
|
||||
"channel_id", ch.ID, "url_host", ch.URLHost,
|
||||
"events", len(fresh), "groups", len(groups))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// loadContentMeta batch-fetches display metadata for every series and movie
|
||||
// in the batch, keyed by content id.
|
||||
func loadContentMeta(ctx context.Context, tx pgx.Tx, events []ReleaseEvent) (map[string]ContentMeta, error) {
|
||||
idSet := make(map[string]struct{}, len(events))
|
||||
ids := make([]string, 0, len(events))
|
||||
add := func(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := idSet[id]; !ok {
|
||||
idSet[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Kind == EventKindMovie {
|
||||
add(event.ItemID)
|
||||
} else {
|
||||
add(event.SeriesID)
|
||||
}
|
||||
}
|
||||
metas := make(map[string]ContentMeta, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return metas, nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT content_id, title, COALESCE(year, 0), COALESCE(type, ''),
|
||||
COALESCE(overview, ''), COALESCE(poster_path, ''),
|
||||
COALESCE(poster_source_path, ''),
|
||||
COALESCE(genres, '{}'::text[]), COALESCE(content_rating, ''),
|
||||
COALESCE(rating_imdb, 0), COALESCE(rating_tmdb, 0),
|
||||
COALESCE(imdb_id, ''), COALESCE(tmdb_id, ''), COALESCE(tvdb_id, '')
|
||||
FROM media_items
|
||||
WHERE content_id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load content metadata: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var meta ContentMeta
|
||||
if err := rows.Scan(&id, &meta.Title, &meta.Year, &meta.Type,
|
||||
&meta.Overview, &meta.PosterPath, &meta.PosterSourcePath,
|
||||
&meta.Genres, &meta.ContentRating,
|
||||
&meta.RatingIMDB, &meta.RatingTMDB,
|
||||
&meta.IMDBID, &meta.TMDBID, &meta.TVDBID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan content metadata: %w", err)
|
||||
}
|
||||
metas[id] = meta
|
||||
}
|
||||
return metas, rows.Err()
|
||||
}
|
||||
|
||||
// requestEventChannels returns the request-subscribed channel list through a
|
||||
// short TTL cache: every request lifecycle event posts through here, and a
|
||||
// reconcile pass can fulfill up to 100 requests back-to-back — the common
|
||||
// no-subscriber case must be a memory check, not a query per event. Newly
|
||||
// toggled channels start posting within the TTL (same staleness budget as
|
||||
// the settings cache).
|
||||
func (w *serverChannelWorker) requestEventChannels(ctx context.Context) ([]ServerChannel, error) {
|
||||
w.requestChannelsMu.Lock()
|
||||
defer w.requestChannelsMu.Unlock()
|
||||
if time.Since(w.requestChannelsFetchedAt) < requestChannelCacheTTL {
|
||||
return w.requestChannels, nil
|
||||
}
|
||||
channels, err := w.repo.ListEnabledForRequests(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.requestChannels = channels
|
||||
w.requestChannelsFetchedAt = time.Now()
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
// PostRequestEvent posts one request lifecycle event to every opted-in
|
||||
// channel, best-effort: failures are recorded for backoff/auto-disable but
|
||||
// never propagate to the request flow.
|
||||
func (w *serverChannelWorker) PostRequestEvent(ctx context.Context, event string, info RequestEventInfo) {
|
||||
if w == nil || !w.settings.ServerChannelsEnabled(ctx) {
|
||||
return
|
||||
}
|
||||
channels, err := w.requestEventChannels(ctx)
|
||||
if err != nil {
|
||||
w.logger.Warn("server channel request post: list channels failed", "error", err)
|
||||
return
|
||||
}
|
||||
// Request posters are raw TMDB paths rendered by the Discord builder;
|
||||
// "off" is the only poster mode that changes them.
|
||||
if w.settings.DiscordPosterMode(ctx) == DiscordPostersOff {
|
||||
info.PosterPath = ""
|
||||
}
|
||||
now := time.Now()
|
||||
for _, ch := range channels {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !ch.WantsRequestEvent(event) {
|
||||
continue
|
||||
}
|
||||
if !channelRetryEligible(now, ch.LastAttemptAt, ch.ConsecutiveFailures) {
|
||||
continue
|
||||
}
|
||||
result := w.sender.sendRequest(ctx, &ch, event, info)
|
||||
if result.OK {
|
||||
if err := w.repo.RecordSendSuccess(ctx, ch.ID); err != nil {
|
||||
w.logger.Warn("server channel success bookkeeping failed", "channel_id", ch.ID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var status *int
|
||||
if result.HTTPStatus > 0 {
|
||||
status = &result.HTTPStatus
|
||||
}
|
||||
if err := w.repo.RecordSendFailure(ctx, ch.ID, status, result.Message); err != nil {
|
||||
w.logger.Warn("server channel failure bookkeeping failed", "channel_id", ch.ID, "error", err)
|
||||
}
|
||||
w.logger.Warn("server channel request post failed",
|
||||
"channel_id", ch.ID, "event", event, "status", result.HTTPStatus, "message", result.Message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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 — except
|
||||
// webhooks and Discord, which are opt-in and stay off until an admin enables
|
||||
// them. 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"
|
||||
|
||||
SettingEmailEnabled = "notifications.email_enabled"
|
||||
SettingEmailAllowPerEpisode = "notifications.email.allow_per_episode"
|
||||
SettingEmailDigestHour = "notifications.email.digest_hour"
|
||||
SettingEmailExternalURL = "notifications.email.external_url"
|
||||
|
||||
SettingDiscordEnabled = "notifications.discord_enabled"
|
||||
SettingDiscordAllowPerEpisode = "notifications.discord.allow_per_episode"
|
||||
SettingDiscordDigestHour = "notifications.discord.digest_hour"
|
||||
SettingDiscordPosterMode = "notifications.discord.poster_mode"
|
||||
|
||||
SettingServerChannelsEnabled = "notifications.server_channels_enabled"
|
||||
SettingServerChannelsBatchSeconds = "notifications.server_channels.batch_seconds"
|
||||
|
||||
// Discord application credentials live under the discord.* namespace
|
||||
// (admin-configured, alongside email.smtp_*). The secret and bot token
|
||||
// are registered in catalog.SensitiveSettingKeys and encrypted at rest.
|
||||
SettingDiscordClientID = "discord.client_id"
|
||||
SettingDiscordClientSecret = "discord.client_secret"
|
||||
SettingDiscordBotToken = "discord.bot_token"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSettleSeconds = 30
|
||||
defaultMaxSeriesBurst = 3
|
||||
defaultMaxEventAgeHours = 72
|
||||
defaultRetentionReadDays = 90
|
||||
defaultRetentionUnread = 180
|
||||
defaultRetentionEventDays = 30
|
||||
// defaultDigestHour applies to every account-level digest channel
|
||||
// (email, Discord).
|
||||
defaultDigestHour = 8
|
||||
|
||||
// defaultServerChannelsBatchSeconds is the server-channel content batch
|
||||
// window: how old a release event must be before the sweep reads it, so a
|
||||
// season pack lands in one grouped post. The minimum must stay >= the
|
||||
// availability detector's timeout (120s): an in-flight availability
|
||||
// transaction can commit events whose created_at predates rows the sweep
|
||||
// already passed, and the window is what keeps those visible.
|
||||
defaultServerChannelsBatchSeconds = 300
|
||||
minServerChannelsBatchSeconds = 120
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Invalidate drops cached values so the next read hits the store. Admin test
|
||||
// paths use it: a test typically runs seconds after a settings save, inside
|
||||
// the read-cache TTL, and must see the just-saved value.
|
||||
func (s *Settings) Invalidate(keys ...string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
for _, key := range keys {
|
||||
delete(s.cache, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
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. Unlike the other
|
||||
// channel flags this is opt-in: letting users point server-originated HTTP at
|
||||
// arbitrary destinations is an admin decision, so creation, test sends, and
|
||||
// delivery all stay off until an admin enables the setting.
|
||||
func (s *Settings) WebhooksEnabled(ctx context.Context) bool {
|
||||
return s.boolSetting(ctx, SettingWebhooksEnabled, false)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// EmailEnabled gates the email notification channel (kill switch). Actual
|
||||
// availability additionally requires a configured SMTP sender (mail.Sender).
|
||||
func (s *Settings) EmailEnabled(ctx context.Context) bool {
|
||||
return s.boolSetting(ctx, SettingEmailEnabled, true)
|
||||
}
|
||||
|
||||
// EmailAllowPerEpisode controls whether users may choose per-episode email
|
||||
// alerts. When off, accounts set to per-episode are coerced to the daily
|
||||
// digest instead of going silent.
|
||||
func (s *Settings) EmailAllowPerEpisode(ctx context.Context) bool {
|
||||
return s.boolSetting(ctx, SettingEmailAllowPerEpisode, true)
|
||||
}
|
||||
|
||||
// EmailDigestHour is the hour of day (0-23, server-local time) at which daily
|
||||
// digest emails go out.
|
||||
func (s *Settings) EmailDigestHour(ctx context.Context) int {
|
||||
return s.intSetting(ctx, SettingEmailDigestHour, defaultDigestHour, 0, 23)
|
||||
}
|
||||
|
||||
// EmailExternalURL is the externally reachable base URL of this server, used
|
||||
// for deep links inside notification emails. Empty renders emails without
|
||||
// links (webhooks deliberately never leak the origin; email is opt-in here).
|
||||
func (s *Settings) EmailExternalURL(ctx context.Context) string {
|
||||
return strings.TrimRight(strings.TrimSpace(s.raw(ctx, SettingEmailExternalURL)), "/")
|
||||
}
|
||||
|
||||
// DiscordEnabled is the master switch for the Discord bot integration. Like
|
||||
// webhooks it is opt-in: while off, the channel never delivers, linking is
|
||||
// refused, and clients are told the channel is unavailable (which hides the
|
||||
// Discord section in user settings). Actual availability additionally
|
||||
// requires the configured bot credentials.
|
||||
func (s *Settings) DiscordEnabled(ctx context.Context) bool {
|
||||
return s.boolSetting(ctx, SettingDiscordEnabled, false)
|
||||
}
|
||||
|
||||
// Discord embed poster modes (SettingDiscordPosterMode values).
|
||||
const (
|
||||
// DiscordPostersOff renders all Discord embeds without artwork.
|
||||
DiscordPostersOff = "off"
|
||||
// DiscordPostersProvider (default) allows artwork served by public
|
||||
// provider CDNs only (image.tmdb.org, artworks.thetvdb.com).
|
||||
DiscordPostersProvider = "provider"
|
||||
// DiscordPostersServer additionally falls back to presigned URLs from
|
||||
// this server's own image storage for locally cached artwork. The
|
||||
// storage origin becomes visible to the destination and must be
|
||||
// reachable from the internet for Discord to render the image.
|
||||
DiscordPostersServer = "server"
|
||||
)
|
||||
|
||||
// DiscordPosterMode controls artwork in outbound Discord embeds; unknown
|
||||
// values fall back to the provider-CDN-only default.
|
||||
func (s *Settings) DiscordPosterMode(ctx context.Context) string {
|
||||
switch strings.TrimSpace(s.raw(ctx, SettingDiscordPosterMode)) {
|
||||
case DiscordPostersOff:
|
||||
return DiscordPostersOff
|
||||
case DiscordPostersServer:
|
||||
return DiscordPostersServer
|
||||
default:
|
||||
return DiscordPostersProvider
|
||||
}
|
||||
}
|
||||
|
||||
// DiscordAllowPerEpisode controls whether users may choose per-episode
|
||||
// Discord DMs. When off, accounts set to per-episode are coerced to the daily
|
||||
// digest instead of going silent.
|
||||
func (s *Settings) DiscordAllowPerEpisode(ctx context.Context) bool {
|
||||
return s.boolSetting(ctx, SettingDiscordAllowPerEpisode, true)
|
||||
}
|
||||
|
||||
// DiscordDigestHour is the hour of day (0-23, server-local time) at which
|
||||
// daily digest DMs go out.
|
||||
func (s *Settings) DiscordDigestHour(ctx context.Context) int {
|
||||
return s.intSetting(ctx, SettingDiscordDigestHour, defaultDigestHour, 0, 23)
|
||||
}
|
||||
|
||||
// ServerChannelsEnabled gates the admin server-channel feature (kill switch).
|
||||
// Defaults to on: unlike profile webhooks, every destination is created by an
|
||||
// admin, so creating a channel is itself the opt-in act.
|
||||
func (s *Settings) ServerChannelsEnabled(ctx context.Context) bool {
|
||||
return s.boolSetting(ctx, SettingServerChannelsEnabled, true)
|
||||
}
|
||||
|
||||
// ServerChannelsBatchWindow is how old a release event must be before the
|
||||
// server-channel sweep reads it (content posts batch within this window).
|
||||
func (s *Settings) ServerChannelsBatchWindow(ctx context.Context) time.Duration {
|
||||
return time.Duration(s.intSetting(ctx, SettingServerChannelsBatchSeconds,
|
||||
defaultServerChannelsBatchSeconds, minServerChannelsBatchSeconds, 3600)) * time.Second
|
||||
}
|
||||
|
||||
// DiscordClientID is the Discord application's OAuth2 client ID.
|
||||
func (s *Settings) DiscordClientID(ctx context.Context) string {
|
||||
return strings.TrimSpace(s.raw(ctx, SettingDiscordClientID))
|
||||
}
|
||||
|
||||
// DiscordClientSecret is the Discord application's OAuth2 client secret.
|
||||
func (s *Settings) DiscordClientSecret(ctx context.Context) string {
|
||||
return strings.TrimSpace(s.raw(ctx, SettingDiscordClientSecret))
|
||||
}
|
||||
|
||||
// DiscordBotToken is the Discord bot token used to open DM channels and send
|
||||
// messages.
|
||||
func (s *Settings) DiscordBotToken(ctx context.Context) string {
|
||||
return strings.TrimSpace(s.raw(ctx, SettingDiscordBotToken))
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/discord"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/mail"
|
||||
"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
|
||||
// ServerChannels is nil when no at-rest cipher is configured (channel
|
||||
// URLs are credentials, same rule as webhooks).
|
||||
ServerChannels *ServerChannelService
|
||||
// WebPush is nil when the settings store is not writable (VAPID keys
|
||||
// could not be provisioned).
|
||||
WebPush *WebPushService
|
||||
// EmailPrefs is nil when no mail sender was provided.
|
||||
EmailPrefs *EmailPrefsRepository
|
||||
// DiscordPrefs holds Discord DM link + mode state; the channel only
|
||||
// delivers once an admin configures bot credentials in settings.
|
||||
DiscordPrefs *DiscordPrefsRepository
|
||||
|
||||
mailSender mail.Sender
|
||||
emailWorker *accountChannelWorker[string]
|
||||
discordWorker *accountChannelWorker[int]
|
||||
discordClient *discord.Client
|
||||
// publicURL is the server's externally reachable base URL, used as the
|
||||
// fallback for tokenized email links (see SetPublicURL).
|
||||
publicURL string
|
||||
|
||||
webhookRepo *WebhookRepository
|
||||
webhookDispatcher *WebhookDispatcher
|
||||
webhookRetry *WebhookRetryWorker
|
||||
webPushRepo *WebPushRepository
|
||||
webPushDispatcher *WebPushDispatcher
|
||||
// serverChannelWorker sweeps release_events into admin broadcast posts;
|
||||
// nil without the at-rest cipher.
|
||||
serverChannelWorker *serverChannelWorker
|
||||
// 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
|
||||
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);
|
||||
// mailSender may be nil (no email channel).
|
||||
func NewSystem(
|
||||
pool *pgxpool.Pool,
|
||||
settingsReader SettingReader,
|
||||
stores userstore.UserStoreProvider,
|
||||
scopes ScopeResolver,
|
||||
users UserLister,
|
||||
hub *evt.Hub,
|
||||
redisClient *redis.Client,
|
||||
cipher *secret.Cipher,
|
||||
mailSender mail.Sender,
|
||||
) *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)
|
||||
webhookService = newWebhookService(webhookRepo, cipher, settings, sender)
|
||||
webhookDispatcher = newWebhookDispatcher(sender)
|
||||
webhookRetry = newWebhookRetryWorker(sender)
|
||||
dispatchers = append(dispatchers, webhookDispatcher)
|
||||
}
|
||||
|
||||
// Admin server channels (broadcast destinations) share the cipher
|
||||
// requirement: their URLs are credentials too.
|
||||
var serverChannelService *ServerChannelService
|
||||
var serverChannelSweep *serverChannelWorker
|
||||
if cipher != nil {
|
||||
serverChannelRepo := NewServerChannelRepository(pool)
|
||||
serverChannelSenderInst := newServerChannelSender(cipher, settings)
|
||||
serverChannelService = newServerChannelService(serverChannelRepo, cipher, settings, serverChannelSenderInst)
|
||||
serverChannelSweep = newServerChannelWorker(pool, serverChannelRepo, releases, serverChannelSenderInst, settings)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Email rides the shared SMTP core. Unlike the per-target channels it
|
||||
// keeps no outbox: its dispatcher only nudges the watermark sweep.
|
||||
var emailPrefs *EmailPrefsRepository
|
||||
var emailChannelInst *emailChannel
|
||||
var emailWorker *accountChannelWorker[string]
|
||||
if mailSender != nil {
|
||||
emailPrefs = NewEmailPrefsRepository(pool)
|
||||
emailChannelInst = &emailChannel{
|
||||
prefs: emailPrefs,
|
||||
deliveries: deliveries,
|
||||
settings: settings,
|
||||
sender: mailSender,
|
||||
}
|
||||
emailWorker = newAccountChannelWorker(pool, emailChannelInst)
|
||||
dispatchers = append(dispatchers, newNudgeDispatcher(emailWorker))
|
||||
}
|
||||
|
||||
// Discord DMs ride the same account-watermark engine as email. The
|
||||
// channel is always wired (its credentials live in settings and may be
|
||||
// configured at runtime); enabled() gates each pass on the bot token.
|
||||
discordPrefs := NewDiscordPrefsRepository(pool)
|
||||
discordClient := discord.NewClient()
|
||||
discordWorker, discordChannelInst := newDiscordWorker(pool, deliveries, discordPrefs, settings, discordClient)
|
||||
dispatchers = append(dispatchers, newNudgeDispatcher(discordWorker))
|
||||
|
||||
multiDispatcher := NewMultiDispatcher(dispatchers...)
|
||||
fanout := NewFanoutWorker(pool, releases, interests, deliveries, preferences, settings, multiDispatcher)
|
||||
if webhookRepo != nil {
|
||||
fanout.SetWebhookOutbox(webhookRepo, newProfileRateLimiter())
|
||||
}
|
||||
if webPushRepo != nil {
|
||||
fanout.SetWebPushOutbox(webPushRepo)
|
||||
}
|
||||
detector := NewAvailabilityDetector(releases, settings)
|
||||
detector.SetFanoutNudge(func() {
|
||||
fanout.Nudge()
|
||||
serverChannelSweep.Nudge() // nil-safe
|
||||
})
|
||||
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,
|
||||
ServerChannels: serverChannelService,
|
||||
WebPush: webPushService,
|
||||
EmailPrefs: emailPrefs,
|
||||
DiscordPrefs: discordPrefs,
|
||||
mailSender: mailSender,
|
||||
emailWorker: emailWorker,
|
||||
discordWorker: discordWorker,
|
||||
discordClient: discordClient,
|
||||
webhookRepo: webhookRepo,
|
||||
webhookDispatcher: webhookDispatcher,
|
||||
webhookRetry: webhookRetry,
|
||||
webPushRepo: webPushRepo,
|
||||
webPushDispatcher: webPushDispatcher,
|
||||
serverChannelWorker: serverChannelSweep,
|
||||
dispatcher: multiDispatcher,
|
||||
pool: pool,
|
||||
stores: stores,
|
||||
users: users,
|
||||
logger: slog.Default().With("component", "notifications.system"),
|
||||
}
|
||||
wsDispatcher.payload = system.PayloadForRow
|
||||
if emailChannelInst != nil {
|
||||
emailChannelInst.profileName = system.lookupProfileName
|
||||
}
|
||||
if sender != nil {
|
||||
sender.operational = system.DispatchOperational
|
||||
sender.posterURL = system.discordPosterURL
|
||||
}
|
||||
discordChannelInst.posterURL = system.discordPosterURL
|
||||
if serverChannelSweep != nil {
|
||||
serverChannelSweep.posterURL = system.discordPosterURL
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// discordPosterURL resolves a delivery's poster to a URL a Discord embed may
|
||||
// carry, honoring the admin's poster mode: nothing when posters are off,
|
||||
// public provider CDN URLs when derivable, and — only on the explicit
|
||||
// "server" opt-in — a presigned URL from this server's own image storage.
|
||||
// Wired into the Discord send paths as their posterURL hook.
|
||||
func (s *System) discordPosterURL(ctx context.Context, posterPath, posterSourcePath string) string {
|
||||
mode := s.Settings.DiscordPosterMode(ctx)
|
||||
if mode == DiscordPostersOff {
|
||||
return ""
|
||||
}
|
||||
if url := embedPosterURL(posterPath, posterSourcePath); url != "" {
|
||||
return url
|
||||
}
|
||||
if mode != DiscordPostersServer || s.images == nil || posterPath == "" {
|
||||
return ""
|
||||
}
|
||||
return s.images.PresignImageURL(ctx, posterPath, "poster", "")
|
||||
}
|
||||
|
||||
// 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.serverChannelWorker != nil {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.serverChannelWorker.Run(ctx)
|
||||
}()
|
||||
}
|
||||
if s.emailWorker != nil {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.emailWorker.Run(ctx)
|
||||
}()
|
||||
}
|
||||
if s.discordWorker != nil {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.discordWorker.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.
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
if s.EmailPrefs != nil {
|
||||
if err := s.EmailPrefs.DeleteForProfile(ctx, profileID); err != nil {
|
||||
return fmt.Errorf("purge email prefs: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedAvailability inserts episode_availability and movie_availability for
|
||||
// every currently playable episode and movie without creating release events,
|
||||
// then writes the per-library, per-kind 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)
|
||||
}
|
||||
}
|
||||
// Episode and movie availability seed independently: each kind has its
|
||||
// own seed markers, because the episode pass historically marked every
|
||||
// scanned library (movie libraries included) with zero movie rows.
|
||||
passes := []struct {
|
||||
kind string
|
||||
seedCondition string
|
||||
record func(ctx context.Context, libraryID int, emitEvents bool) (int, int, error)
|
||||
}{
|
||||
{EventKindEpisode,
|
||||
`SELECT 1 FROM notification_library_seed_state seed WHERE seed.library_id = mf.id`,
|
||||
s.Releases.RecordAvailabilityForLibrary},
|
||||
{EventKindMovie,
|
||||
`SELECT 1 FROM notification_content_seed_state seed WHERE seed.library_id = mf.id AND seed.kind = 'movie'`,
|
||||
s.Releases.RecordMovieAvailabilityForLibrary},
|
||||
}
|
||||
for passIdx, pass := range passes {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT mf.id
|
||||
FROM media_folders mf
|
||||
WHERE mf.last_scanned_at IS NOT NULL
|
||||
AND NOT EXISTS (`+pass.seedCondition+`)
|
||||
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 := pass.record(ctx, libraryID, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed library %d (%s): %w", libraryID, pass.kind, err)
|
||||
}
|
||||
if err := s.Releases.MarkContentSeeded(ctx, libraryID, pass.kind); err != nil {
|
||||
return fmt.Errorf("mark library %d seeded (%s): %w", libraryID, pass.kind, err)
|
||||
}
|
||||
totalSeeded += inserted
|
||||
// Each pass owns half the progress range.
|
||||
passBase := passIdx * 50
|
||||
report(passBase+(i+1)*50/max(len(libraryIDs), 1),
|
||||
fmt.Sprintf("Seeded library %d %s availability (%d new rows)", libraryID, pass.kind, inserted))
|
||||
}
|
||||
s.logger.Info("availability seeding completed",
|
||||
"kind", pass.kind, "libraries", len(libraryIDs), "availability_rows", totalSeeded)
|
||||
}
|
||||
report(100, "Availability seeding completed")
|
||||
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, 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)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
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
|
||||
DiscordLinkStatesDeleted 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
|
||||
}
|
||||
if s.DiscordPrefs != nil {
|
||||
states, err := s.DiscordPrefs.DeleteExpiredLinkStates(ctx)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("prune discord link states: %w", err)
|
||||
}
|
||||
stats.DiscordLinkStatesDeleted = states
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 != schemeHTTPS {
|
||||
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/")
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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 != schemeHTTPS {
|
||||
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")
|
||||
}
|
||||
// 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 {
|
||||
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 {
|
||||
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 {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mapSettingReader is a SettingReader fake; missing keys read as unset.
|
||||
type mapSettingReader map[string]string
|
||||
|
||||
func (m mapSettingReader) Get(_ context.Context, key string) (string, error) {
|
||||
return m[key], nil
|
||||
}
|
||||
|
||||
func TestWebhooksDisabledByDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if NewSettings(nil).WebhooksEnabled(ctx) {
|
||||
t.Fatal("WebhooksEnabled must default to false until an admin opts in")
|
||||
}
|
||||
if !NewSettings(mapSettingReader{SettingWebhooksEnabled: "true"}).WebhooksEnabled(ctx) {
|
||||
t.Fatal("WebhooksEnabled = false with the setting on, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookCreateAndTestBlockedWhenDisabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newWebhookService(nil, nil, NewSettings(nil), nil)
|
||||
|
||||
name, url := "hook", "https://discord.com/api/webhooks/1/abc"
|
||||
if _, _, err := service.Create(ctx, 1, "profile", WebhookInput{Name: &name, URL: &url}); !errors.Is(err, ErrWebhooksDisabled) {
|
||||
t.Fatalf("Create error = %v, want ErrWebhooksDisabled", err)
|
||||
}
|
||||
if _, err := service.Test(ctx, "profile", "hook-id"); !errors.Is(err, ErrWebhooksDisabled) {
|
||||
t.Fatalf("Test error = %v, want ErrWebhooksDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
PosterPath: testSeriesPosterPath,
|
||||
PosterURL: testSeriesPosterCDN,
|
||||
MediaType: "series",
|
||||
SeriesOverview: "Mark leads a team whose memories have been surgically divided.",
|
||||
EpisodeOverview: "Mark is promoted after the disappearance of his colleague.",
|
||||
Genres: []string{"Drama", "Sci-Fi & Fantasy"},
|
||||
ContentRating: "TV-MA",
|
||||
RatingIMDB: 8.7,
|
||||
IMDBID: "tt11280740",
|
||||
TMDBID: "95396",
|
||||
TVDBID: "371980",
|
||||
}
|
||||
}
|
||||
|
||||
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 []discordEmbed `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.Author == nil || embed.Author.Name != "New episode on Silo" {
|
||||
t.Fatalf("unexpected author %+v", embed.Author)
|
||||
}
|
||||
if embed.URL != "https://www.themoviedb.org/tv/95396" {
|
||||
t.Fatalf("unexpected title URL %q", embed.URL)
|
||||
}
|
||||
if embed.Thumbnail == nil || embed.Thumbnail.URL != testSeriesPosterCDN {
|
||||
t.Fatalf("unexpected thumbnail %+v", embed.Thumbnail)
|
||||
}
|
||||
// Episode overview wins over the series overview; provider links follow.
|
||||
if !strings.HasPrefix(embed.Description, "Mark is promoted") ||
|
||||
!strings.Contains(embed.Description, "[TMDB](https://www.themoviedb.org/tv/95396)") ||
|
||||
!strings.Contains(embed.Description, "[IMDb](https://www.imdb.com/title/tt11280740/)") ||
|
||||
!strings.Contains(embed.Description, "[TVDB](https://thetvdb.com/dereferrer/series/371980)") {
|
||||
t.Fatalf("unexpected description %q", embed.Description)
|
||||
}
|
||||
if len(embed.Fields) != 3 ||
|
||||
embed.Fields[0].Name != "Reason" || embed.Fields[0].Value != "Favorited & Continue Watching" ||
|
||||
embed.Fields[1].Value != "★ 8.7 IMDb" ||
|
||||
embed.Fields[2].Value != "Drama, Sci-Fi & Fantasy" {
|
||||
t.Fatalf("unexpected fields: %+v", embed.Fields)
|
||||
}
|
||||
if embed.Footer == nil || embed.Footer.Text != "Silo • TV-MA" {
|
||||
t.Fatalf("unexpected footer %+v", embed.Footer)
|
||||
}
|
||||
// The privacy contract: only public provider origins may appear — never
|
||||
// this server's own URL (which the builder cannot even see).
|
||||
for _, origin := range allOriginsIn(t, string(payload)) {
|
||||
switch origin {
|
||||
case "www.themoviedb.org", "image.tmdb.org", "www.imdb.com", "thetvdb.com":
|
||||
default:
|
||||
t.Fatalf("payload names non-provider origin %q: %s", origin, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// allOriginsIn extracts every http(s) host named anywhere in the payload.
|
||||
func allOriginsIn(t *testing.T, payload string) []string {
|
||||
t.Helper()
|
||||
hosts := make([]string, 0, 4)
|
||||
rest := payload
|
||||
for {
|
||||
at := strings.Index(rest, "https://")
|
||||
if at < 0 {
|
||||
break
|
||||
}
|
||||
rest = rest[at+len("https://"):]
|
||||
end := strings.IndexAny(rest, "/\"\\)")
|
||||
if end < 0 {
|
||||
end = len(rest)
|
||||
}
|
||||
hosts = append(hosts, rest[:end])
|
||||
}
|
||||
if strings.Contains(payload, "http://") {
|
||||
t.Fatalf("payload contains insecure http:// URL: %s", payload)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func TestBuildDiscordWebhookPayloadWithoutPosterURL(t *testing.T) {
|
||||
row := webhookTestRow()
|
||||
// The poster decision is the sender layer's; a row without a resolved
|
||||
// PosterURL must render without an image regardless of stored paths.
|
||||
row.PosterURL = ""
|
||||
payload, err := BuildDiscordWebhookPayload(row, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(payload), `"thumbnail"`) {
|
||||
t.Fatalf("rows without a resolved poster URL must not render a thumbnail: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// fakePresigner fakes the catalog image resolver: every path presigns to a
|
||||
// recognizable server-storage URL.
|
||||
type fakePresigner struct{}
|
||||
|
||||
func (fakePresigner) PresignImageURL(_ context.Context, path, _, _ string) string {
|
||||
return "https://s3.example.com/" + path + "?sig=abc"
|
||||
}
|
||||
|
||||
func TestDiscordPosterURLModes(t *testing.T) {
|
||||
const cachedKey = "tmdb/series/95396/poster/original.jpg"
|
||||
system := func(mode string, images ImageURLResolver) *System {
|
||||
return &System{
|
||||
Settings: NewSettings(mapSettingReader{SettingDiscordPosterMode: mode}),
|
||||
images: images,
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Off: nothing renders, even provider-CDN-resolvable artwork.
|
||||
if got := system("off", fakePresigner{}).discordPosterURL(ctx, testSeriesPosterPath, ""); got != "" {
|
||||
t.Fatalf("mode off must drop posters, got %q", got)
|
||||
}
|
||||
// Provider (default): public CDN URLs only; cached keys never presign.
|
||||
if got := system("", fakePresigner{}).discordPosterURL(ctx, testSeriesPosterPath, ""); got != testSeriesPosterCDN {
|
||||
t.Fatalf("provider mode CDN resolution failed, got %q", got)
|
||||
}
|
||||
if got := system("", fakePresigner{}).discordPosterURL(ctx, cachedKey, ""); got != "" {
|
||||
t.Fatalf("provider mode must not presign cached keys, got %q", got)
|
||||
}
|
||||
// Server: provider CDN still wins; cached keys presign as the fallback.
|
||||
if got := system("server", fakePresigner{}).discordPosterURL(ctx, cachedKey, testSeriesPosterPath); got != testSeriesPosterCDN {
|
||||
t.Fatalf("server mode must still prefer provider CDN, got %q", got)
|
||||
}
|
||||
if got := system("server", fakePresigner{}).discordPosterURL(ctx, cachedKey, ""); got != "https://s3.example.com/"+cachedKey+"?sig=abc" {
|
||||
t.Fatalf("server mode presign fallback failed, got %q", got)
|
||||
}
|
||||
// Server without a wired resolver degrades to no image.
|
||||
if got := system("server", nil).discordPosterURL(ctx, cachedKey, ""); got != "" {
|
||||
t.Fatalf("server mode without resolver must render no image, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
MediaType: "movie",
|
||||
Year: 2021,
|
||||
SeriesOverview: "Paul Atreides, a brilliant and gifted young man.",
|
||||
RatingTMDB: 7.8,
|
||||
TMDBID: "438631",
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDiscordWebhookPayloadRequestFulfilled(t *testing.T) {
|
||||
payload, err := BuildDiscordWebhookPayload(requestFulfilledTestRow(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("build failed: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Embeds []discordEmbed `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 (2021)" {
|
||||
t.Fatalf("unexpected title %q", embed.Title)
|
||||
}
|
||||
if embed.Author == nil || embed.Author.Name != "Your request is now available on Silo" {
|
||||
t.Fatalf("unexpected author %+v", embed.Author)
|
||||
}
|
||||
if embed.URL != "https://www.themoviedb.org/movie/438631" {
|
||||
t.Fatalf("unexpected title URL %q", embed.URL)
|
||||
}
|
||||
if !strings.HasPrefix(embed.Description, "Paul Atreides") {
|
||||
t.Fatalf("unexpected description %q", embed.Description)
|
||||
}
|
||||
if len(embed.Fields) != 2 ||
|
||||
embed.Fields[0].Name != "Type" || embed.Fields[0].Value != "Movie" ||
|
||||
embed.Fields[1].Name != "Rating" || embed.Fields[1].Value != "★ 7.8 TMDB" {
|
||||
t.Fatalf("expected Type and Rating fields, 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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type discordEmbedAuthor struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type discordEmbedMedia struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// discordEmbed names external origins under the admin's poster mode
|
||||
// (System.discordPosterURL): public provider services (themoviedb.org,
|
||||
// imdb.com, thetvdb.com and their image CDNs) by default, plus presigned
|
||||
// server-storage URLs only under the explicit "server" opt-in — Discord
|
||||
// fetches thumbnail URLs and the raw payload is visible to channel members,
|
||||
// so a self-hosted URL reveals the server's address (docs/superpowers/plans/
|
||||
// notifications/04, "Server URL leakage"). Builders never derive artwork
|
||||
// URLs themselves; they render the PosterURL the sender layer resolved.
|
||||
type discordEmbed struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Color int `json:"color"`
|
||||
Author *discordEmbedAuthor `json:"author,omitempty"`
|
||||
Thumbnail *discordEmbedMedia `json:"thumbnail,omitempty"`
|
||||
Footer *discordEmbedFooter `json:"footer,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Fields []discordEmbedField `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type discordWebhookBody struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Embeds []discordEmbed `json:"embeds"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// BuildDiscordWebhookPayload renders a delivery as a Discord webhook body.
|
||||
// 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) {
|
||||
return json.Marshal(discordWebhookBody{
|
||||
Embeds: []discordEmbed{buildDiscordEmbed(row, test)},
|
||||
Username: siloSenderName,
|
||||
})
|
||||
}
|
||||
|
||||
// discordDMMaxEmbeds is Discord's per-message embed cap.
|
||||
const discordDMMaxEmbeds = 10
|
||||
|
||||
// discordDMBody is a bot channel-message body. Unlike webhook bodies it has
|
||||
// no username override: a bot message always carries the bot's own identity.
|
||||
type discordDMBody struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Embeds []discordEmbed `json:"embeds"`
|
||||
}
|
||||
|
||||
// BuildDiscordDMPayload renders one account's pending deliveries as a single
|
||||
// bot DM, one embed per item up to Discord's 10-embed cap. Overflow keeps the
|
||||
// newest items and points at the Silo inbox for the rest, mirroring the email
|
||||
// digest's rendering cap.
|
||||
func BuildDiscordDMPayload(rows []DeliveryRow) ([]byte, error) {
|
||||
overflow := 0
|
||||
if len(rows) > discordDMMaxEmbeds {
|
||||
overflow = len(rows) - discordDMMaxEmbeds
|
||||
rows = rows[len(rows)-discordDMMaxEmbeds:]
|
||||
}
|
||||
embeds := make([]discordEmbed, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
embeds = append(embeds, buildDiscordEmbed(row, false))
|
||||
}
|
||||
body := discordDMBody{Embeds: embeds}
|
||||
if overflow > 0 {
|
||||
body.Content = fmt.Sprintf("…and %d more in your Silo inbox", overflow)
|
||||
}
|
||||
return json.Marshal(body)
|
||||
}
|
||||
|
||||
// discordEmbedAuthorLine renders the small "what happened" line above the
|
||||
// embed title.
|
||||
func discordEmbedAuthorLine(deliveryType string) string {
|
||||
switch deliveryType {
|
||||
case DeliveryTypeEpisodeAvailable:
|
||||
return "New episode on Silo"
|
||||
case DeliveryTypeRequestFulfilled:
|
||||
return "Your request is now available on Silo"
|
||||
default:
|
||||
return genericNotificationTitle
|
||||
}
|
||||
}
|
||||
|
||||
// discordEmbedFooterText renders the footer: the sender brand, plus the
|
||||
// content rating when known so the advisory rides along unobtrusively.
|
||||
func discordEmbedFooterText(contentRating string, test bool) string {
|
||||
if test {
|
||||
return "Silo test notification"
|
||||
}
|
||||
if contentRating != "" {
|
||||
return siloSenderName + " • " + truncateWithEllipsis(contentRating, 32)
|
||||
}
|
||||
return siloSenderName
|
||||
}
|
||||
|
||||
// buildDiscordEmbed renders one delivery as a Discord embed within all of
|
||||
// Discord's per-embed limits: poster thumbnail, overview teaser, provider
|
||||
// links, rating, and genres on top of the title/reason basics. The
|
||||
// season/episode code lives in the title, so no dedicated fields repeat it.
|
||||
func buildDiscordEmbed(row DeliveryRow, test bool) discordEmbed {
|
||||
flags := parseReasonFlags(row.ReasonFlags)
|
||||
|
||||
// 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(discordEmbedTitle(row), discordTitleLimit)
|
||||
|
||||
overview := row.SeriesOverview
|
||||
if row.Type == DeliveryTypeEpisodeAvailable && row.EpisodeOverview != "" {
|
||||
overview = row.EpisodeOverview
|
||||
}
|
||||
ids := providerIDs{MediaType: row.MediaType, IMDB: row.IMDBID, TMDB: row.TMDBID, TVDB: row.TVDBID}
|
||||
|
||||
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.Type == DeliveryTypeRequestFulfilled {
|
||||
if mediaType := requestMediaTypeLabel(row.ReasonFlags); mediaType != "" {
|
||||
fields = append(fields, discordEmbedField{Name: "Type", Value: mediaType, Inline: true})
|
||||
}
|
||||
}
|
||||
if rating := ratingLabel(row.RatingIMDB, row.RatingTMDB); rating != "" {
|
||||
fields = append(fields, discordEmbedField{Name: "Rating", Value: rating, Inline: true})
|
||||
}
|
||||
if genres := genresLabel(row.Genres); genres != "" {
|
||||
fields = append(fields, discordEmbedField{Name: "Genres", Value: genres, Inline: true})
|
||||
}
|
||||
|
||||
embed := discordEmbed{
|
||||
Title: title,
|
||||
URL: ids.titleURL(),
|
||||
Description: embedDescription(overview, ids),
|
||||
Color: discordEmbedColor(flags),
|
||||
Author: &discordEmbedAuthor{Name: discordEmbedAuthorLine(row.Type)},
|
||||
Footer: &discordEmbedFooter{Text: discordEmbedFooterText(row.ContentRating, test)},
|
||||
Fields: fields,
|
||||
}
|
||||
// The poster decision (provider CDN vs presigned vs none) is the sender
|
||||
// layer's: builders render whatever PosterURL carries.
|
||||
if row.PosterURL != "" {
|
||||
embed.Thumbnail = &discordEmbedMedia{URL: row.PosterURL}
|
||||
}
|
||||
if !row.CreatedAt.IsZero() {
|
||||
embed.Timestamp = row.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
enforceDiscordTotalLimit(&embed)
|
||||
return embed
|
||||
}
|
||||
|
||||
func discordEmbedTitle(row DeliveryRow) string {
|
||||
switch row.Type {
|
||||
case DeliveryTypeRequestFulfilled:
|
||||
if row.SeriesTitle != "" {
|
||||
return titleWithYear(row.SeriesTitle, row.Year)
|
||||
}
|
||||
return "Request fulfilled"
|
||||
case DeliveryTypeEpisodeAvailable:
|
||||
// Falls out of the switch into the episode title assembly below.
|
||||
default:
|
||||
return genericNotificationTitle
|
||||
}
|
||||
series := row.SeriesTitle
|
||||
if series == "" {
|
||||
series = genericEpisodeTitle
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// requestMediaTypeLabel renders a request.fulfilled delivery's media type as
|
||||
// a display label; unknown values render nothing.
|
||||
func requestMediaTypeLabel(reasonFlags []byte) string {
|
||||
return mediaTypeLabel(parseRequestFulfilledFlags(reasonFlags).MediaType)
|
||||
}
|
||||
|
||||
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.Author != nil {
|
||||
total += len(embed.Author.Name)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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"`
|
||||
// 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 {
|
||||
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"`
|
||||
}
|
||||
|
||||
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) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
if row.Type == DeliveryTypeRequestFulfilled {
|
||||
flags := parseRequestFulfilledFlags(row.ReasonFlags)
|
||||
body.Request = &genericWebhookRequest{
|
||||
ID: flags.RequestID,
|
||||
TMDBID: flags.TMDBID,
|
||||
MediaType: flags.MediaType,
|
||||
}
|
||||
}
|
||||
return json.Marshal(body)
|
||||
}
|
||||
|
||||
// SignGenericWebhook computes the X-Silo-Signature header value for a body:
|
||||
// "t=<epoch>,v1=<hex(hmac_sha256(secret, "<epoch>.<body>"))>", 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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"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,
|
||||
notify_requests,
|
||||
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.NotifyRequests,
|
||||
&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
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// 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,
|
||||
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.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
|
||||
}
|
||||
|
||||
// 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,
|
||||
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 isWebhookNameViolation(err) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// 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)
|
||||
// posterURL picks the artwork URL Discord embeds may carry (admin poster
|
||||
// mode + provider-CDN/presign resolution). Wired by NewSystem after
|
||||
// construction; nil renders embeds without images.
|
||||
posterURL func(ctx context.Context, posterPath, posterSourcePath string) string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func newWebhookSender(
|
||||
webhooks *WebhookRepository,
|
||||
deliveries *DeliveryRepository,
|
||||
cipher *secret.Cipher,
|
||||
settings *Settings,
|
||||
) *webhookSender {
|
||||
sender := &webhookSender{
|
||||
webhooks: webhooks,
|
||||
deliveries: deliveries,
|
||||
cipher: cipher,
|
||||
settings: settings,
|
||||
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"}
|
||||
}
|
||||
if hook.Type == WebhookTypeDiscord && s.posterURL != nil {
|
||||
row.PosterURL = s.posterURL(ctx, row.PosterPath, row.PosterSourcePath)
|
||||
}
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
if s.operational == nil {
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
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")
|
||||
ErrWebhooksDisabled = errors.New("webhooks are disabled by the administrator")
|
||||
)
|
||||
|
||||
// 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
|
||||
NotifyRequests *bool
|
||||
}
|
||||
|
||||
// validateChannelName applies the shared destination-name policy (matching
|
||||
// the varchar(64) columns); invalid is the caller's sentinel to wrap.
|
||||
func validateChannelName(name string, invalid error) (string, error) {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("%w: name is required", invalid)
|
||||
}
|
||||
if len(trimmed) > 64 {
|
||||
return "", fmt.Errorf("%w: name must be 64 characters or fewer", invalid)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// resolveWebhookType infers or validates a destination type against its URL.
|
||||
// An empty explicit type auto-detects; an explicit type must match the
|
||||
// destination, or the sender would apply the wrong payload/signing behavior
|
||||
// from the first delivery. Shared by profile webhooks and server channels.
|
||||
func resolveWebhookType(rawURL, explicitType string, invalid error) (string, error) {
|
||||
isDiscordURL := discordWebhookURL(rawURL)
|
||||
switch explicitType {
|
||||
case "":
|
||||
if isDiscordURL {
|
||||
return WebhookTypeDiscord, nil
|
||||
}
|
||||
return WebhookTypeGeneric, nil
|
||||
case WebhookTypeDiscord, WebhookTypeGeneric:
|
||||
default:
|
||||
return "", fmt.Errorf("%w: type must be discord or generic", invalid)
|
||||
}
|
||||
if explicitType == WebhookTypeDiscord && !isDiscordURL {
|
||||
return "", fmt.Errorf("%w: type discord requires a Discord webhook URL", invalid)
|
||||
}
|
||||
if explicitType == WebhookTypeGeneric && isDiscordURL {
|
||||
return "", fmt.Errorf("%w: Discord webhook URLs must use type discord", invalid)
|
||||
}
|
||||
return explicitType, nil
|
||||
}
|
||||
|
||||
// validateReplacementURL checks a replacement URL stays compatible with the
|
||||
// destination's fixed type, so existing receivers keep working.
|
||||
func validateReplacementURL(hookType, rawURL string, invalid error) error {
|
||||
isDiscordURL := discordWebhookURL(rawURL)
|
||||
if hookType == WebhookTypeDiscord && !isDiscordURL {
|
||||
return fmt.Errorf("%w: a Discord webhook needs a Discord webhook URL", invalid)
|
||||
}
|
||||
if hookType == WebhookTypeGeneric && isDiscordURL {
|
||||
return fmt.Errorf("%w: Discord webhook URLs must use type discord", invalid)
|
||||
}
|
||||
return 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) {
|
||||
// Webhooks are opt-in: creation is blocked until an admin enables the
|
||||
// channel. Existing webhooks stay manageable (list/update/delete) so a
|
||||
// later disable never strands rows users can no longer remove.
|
||||
if !s.settings.WebhooksEnabled(ctx) {
|
||||
return nil, "", ErrWebhooksDisabled
|
||||
}
|
||||
if input.Name == nil || input.URL == nil {
|
||||
return nil, "", fmt.Errorf("%w: name and url are required", ErrWebhookInvalid)
|
||||
}
|
||||
name, err := validateChannelName(*input.Name, ErrWebhookInvalid)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
hookType, err = resolveWebhookType(rawURL, hookType, ErrWebhookInvalid)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
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),
|
||||
NotifyRequests: boolOrDefault(input.NotifyRequests, 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
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
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 := validateChannelName(*input.Name, ErrWebhookInvalid)
|
||||
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())
|
||||
}
|
||||
if err := validateReplacementURL(hook.Type, rawURL, ErrWebhookInvalid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 input.NotifyRequests != nil {
|
||||
hook.NotifyRequests = *input.NotifyRequests
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// testResult converts a send outcome to the API test-result shape.
|
||||
func (r webhookSendResult) testResult() *WebhookTestResult {
|
||||
return &WebhookTestResult{
|
||||
OK: r.OK,
|
||||
HTTPStatus: r.HTTPStatus,
|
||||
DurationMS: r.Duration.Milliseconds(),
|
||||
Message: r.Message,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Test sends are outbound traffic; the channel gate covers them too
|
||||
// (regular delivery is already gated at enqueue and dispatch).
|
||||
if !s.settings.WebhooksEnabled(ctx) {
|
||||
return nil, ErrWebhooksDisabled
|
||||
}
|
||||
hook, err := s.repo.GetByID(ctx, profileID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hook == nil {
|
||||
return nil, ErrWebhookNotFound
|
||||
}
|
||||
return s.sender.send(ctx, hook, sampleDeliveryRow(profileID), true).testResult(), 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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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"
|
||||
|
||||
// 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 {
|
||||
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
|
||||
NotifyRequests 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
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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("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, "")
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit web push upsert: %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
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"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 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."
|
||||
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 {
|
||||
// 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 {
|
||||
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 {
|
||||
retryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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, 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
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
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, 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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
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. 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 vapidKeypair{}, false, nil
|
||||
}
|
||||
raw, err := s.settings.reader.Get(ctx, SettingWebPushVAPIDKeypair)
|
||||
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{}, false, fmt.Errorf("decode stored VAPID keypair: %w", err)
|
||||
}
|
||||
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.
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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 }
|
||||
|
||||
// LifecycleNotifier observes request lifecycle transitions (submitted,
|
||||
// approved, declined) for broadcast destinations such as admin server
|
||||
// channels. Implementations must be fast and non-blocking (dispatch async)
|
||||
// and must never fail the transition: methods return nothing.
|
||||
//
|
||||
// Fulfillment is deliberately not part of this interface — it stays on
|
||||
// FulfillmentNotifier, whose presence-checked, idempotent flow runs on the
|
||||
// reconcile service rather than the API service.
|
||||
type LifecycleNotifier interface {
|
||||
RequestSubmitted(ctx context.Context, req Request)
|
||||
RequestApproved(ctx context.Context, req Request)
|
||||
RequestDeclined(ctx context.Context, req Request)
|
||||
}
|
||||
|
||||
// SetLifecycleNotifier wires lifecycle observation into the API-facing
|
||||
// service. Optional; without it transitions are not broadcast.
|
||||
func (s *Service) SetLifecycleNotifier(n LifecycleNotifier) { s.lifecycle = n }
|
||||
|
||||
// notifyLifecycle resolves requester display identity and invokes one
|
||||
// lifecycle hook. Best-effort by construction: the notifier cannot return an
|
||||
// error and identity resolution failures just leave the name empty.
|
||||
func (s *Service) notifyLifecycle(ctx context.Context, req Request, notify func(LifecycleNotifier, context.Context, Request)) {
|
||||
if s.lifecycle == nil {
|
||||
return
|
||||
}
|
||||
s.populateRequesterIdentity(ctx, &req)
|
||||
notify(s.lifecycle, ctx, req)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -47,6 +47,8 @@ type Service struct {
|
||||
router RequestRouterProvider
|
||||
entitlements EntitlementResolver
|
||||
requesterIdentity RequesterIdentityResolver
|
||||
notifier FulfillmentNotifier
|
||||
lifecycle LifecycleNotifier
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
@@ -455,7 +457,11 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.notifyLifecycle(ctx, *req, LifecycleNotifier.RequestSubmitted)
|
||||
if req.Status == StatusApproved {
|
||||
// Auto-approval is a real approval transition; channels subscribed to
|
||||
// approvals see it alongside the submission.
|
||||
s.notifyLifecycle(ctx, *req, LifecycleNotifier.RequestApproved)
|
||||
return s.submitApprovedRequest(ctx, *req, viewer, nil)
|
||||
}
|
||||
return req, nil
|
||||
@@ -592,6 +598,7 @@ func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Reque
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.notifyLifecycle(ctx, *approved, LifecycleNotifier.RequestApproved)
|
||||
return s.submitApprovedRequest(ctx, *approved, viewer, nil)
|
||||
}
|
||||
|
||||
@@ -614,7 +621,12 @@ func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string)
|
||||
strings.TrimSpace(req.IntegrationKind) != "" {
|
||||
return nil, ErrInvalidState
|
||||
}
|
||||
return s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason)
|
||||
declined, err := s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.notifyLifecycle(ctx, *declined, LifecycleNotifier.RequestDeclined)
|
||||
return declined, nil
|
||||
}
|
||||
|
||||
// Cancel withdraws a request that has not yet been submitted to a downstream
|
||||
@@ -719,6 +731,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
)
|
||||
|
||||
// SeedContentAvailabilityTask inserts episode_availability and
|
||||
// movie_availability rows for every currently playable episode and movie
|
||||
// without creating release events, then writes the per-library, per-kind 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 SeedContentAvailabilityTask struct {
|
||||
system *notifications.System
|
||||
}
|
||||
|
||||
// NewSeedContentAvailabilityTask creates the seeding task.
|
||||
func NewSeedContentAvailabilityTask(system *notifications.System) *SeedContentAvailabilityTask {
|
||||
return &SeedContentAvailabilityTask{system: system}
|
||||
}
|
||||
|
||||
func (t *SeedContentAvailabilityTask) Key() string { return "seed_content_availability" }
|
||||
func (t *SeedContentAvailabilityTask) Name() string { return "Seed Content Availability" }
|
||||
func (t *SeedContentAvailabilityTask) Description() string {
|
||||
return "Records the existing episode and movie back-catalog as already-released so new-content notifications only fire for items that arrive afterwards."
|
||||
}
|
||||
func (t *SeedContentAvailabilityTask) Category() taskmanager.TaskCategory {
|
||||
return taskmanager.TaskCategorySystem
|
||||
}
|
||||
func (t *SeedContentAvailabilityTask) IsHidden() bool { return true }
|
||||
|
||||
func (t *SeedContentAvailabilityTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
||||
return []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeStartup}}
|
||||
}
|
||||
|
||||
func (t *SeedContentAvailabilityTask) 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 content availability")
|
||||
if err := t.system.SeedAvailability(ctx, func(percent int, message string) {
|
||||
progress.Report(float64(percent), message)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("seeding content availability: %w", err)
|
||||
}
|
||||
progress.Report(100, "Content 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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- Email notification channel (docs/superpowers/plans/notifications/06,
|
||||
-- item 3). Email addresses live on login accounts (users), not profiles, so
|
||||
-- the mode and dispatch state are account-level. The email worker sweeps
|
||||
-- notification_deliveries per user and advances the watermark only after a
|
||||
-- successful SMTP send, so a crash or SMTP outage re-sends instead of
|
||||
-- dropping; the watermark is initialized to now() whenever the channel is
|
||||
-- enabled so history never floods a fresh opt-in.
|
||||
CREATE TABLE public.notification_email_prefs (
|
||||
user_id integer PRIMARY KEY,
|
||||
mode text NOT NULL DEFAULT 'off'
|
||||
CHECK (mode IN ('off', 'per_episode', 'daily_digest')),
|
||||
watermark_created_at timestamptz NOT NULL DEFAULT now(),
|
||||
watermark_id text NOT NULL DEFAULT '',
|
||||
last_digest_at timestamptz,
|
||||
last_attempt_at timestamptz,
|
||||
consecutive_failures integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Deliberately no FK to users: notification tables stay FK-free toward
|
||||
-- account/profile storage (see 20260611100000). Rows for deleted accounts
|
||||
-- drop out of the recipient join and are inert.
|
||||
|
||||
-- The email sweep reads deliveries by account, not profile.
|
||||
CREATE INDEX notification_deliveries_user_created_idx
|
||||
ON public.notification_deliveries (user_id, created_at, id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS public.notification_deliveries_user_created_idx;
|
||||
DROP TABLE IF EXISTS public.notification_email_prefs;
|
||||
-- +goose StatementEnd
|
||||
@@ -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
|
||||
@@ -0,0 +1,56 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- Discord DM notification channel (docs/superpowers/plans/notifications).
|
||||
-- Account-level like email: the OAuth-linked Discord identity belongs to the
|
||||
-- login account (users), not a profile, so one row carries the identity, the
|
||||
-- user-chosen mode, and the watermark sweep state. The worker advances the
|
||||
-- watermark only after a successful DM, so a crash or Discord outage re-sends
|
||||
-- instead of dropping; the watermark resets to now() on link and on enabling
|
||||
-- so history never floods a fresh opt-in.
|
||||
--
|
||||
-- Deliberately no FK to users: notification tables stay FK-free toward
|
||||
-- account/profile storage (see 20260611100000). Rows for deleted accounts
|
||||
-- drop out of the recipient join and are inert.
|
||||
CREATE TABLE public.notification_discord_prefs (
|
||||
user_id integer PRIMARY KEY,
|
||||
-- OAuth-linked identity; empty until the user completes the link flow.
|
||||
discord_user_id text NOT NULL DEFAULT '',
|
||||
discord_username text NOT NULL DEFAULT '',
|
||||
-- DM channel cache from POST /users/@me/channels (DM channels are
|
||||
-- permanent, so the cache never expires; it is only a saved round-trip).
|
||||
dm_channel_id text NOT NULL DEFAULT '',
|
||||
mode text NOT NULL DEFAULT 'off'
|
||||
CHECK (mode IN ('off', 'per_episode', 'daily_digest')),
|
||||
watermark_created_at timestamptz NOT NULL DEFAULT now(),
|
||||
watermark_id text NOT NULL DEFAULT '',
|
||||
last_digest_at timestamptz,
|
||||
last_attempt_at timestamptz,
|
||||
consecutive_failures integer NOT NULL DEFAULT 0,
|
||||
-- Last DM failure surfaced in the settings UI ('' = healthy). Typically
|
||||
-- Discord error 50007: the user does not share a server with the bot.
|
||||
link_failure text NOT NULL DEFAULT '',
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One-time state rows for the Discord OAuth linking flow. The state is a
|
||||
-- 32-byte random value; the atomic DELETE RETURNING on callback makes each
|
||||
-- state single-use, which is the flow's authentication. Expired rows are
|
||||
-- reaped by retention.
|
||||
CREATE TABLE public.notification_discord_link_state (
|
||||
state text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX notification_discord_link_state_expires_idx
|
||||
ON public.notification_discord_link_state (expires_at);
|
||||
|
||||
-- The Discord sweep reads deliveries by account via
|
||||
-- notification_deliveries_user_created_idx (created in 20260611201720).
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS public.notification_discord_link_state;
|
||||
DROP TABLE IF EXISTS public.notification_discord_prefs;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,39 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- Adds the combined account-channel mode: per-episode sends all day plus a
|
||||
-- daily digest recapping everything since the previous digest. Applies to
|
||||
-- both account-level channels (email, Discord DMs).
|
||||
ALTER TABLE public.notification_email_prefs
|
||||
DROP CONSTRAINT notification_email_prefs_mode_check;
|
||||
ALTER TABLE public.notification_email_prefs
|
||||
ADD CONSTRAINT notification_email_prefs_mode_check
|
||||
CHECK (mode IN ('off', 'per_episode', 'daily_digest', 'per_episode_and_digest'));
|
||||
|
||||
ALTER TABLE public.notification_discord_prefs
|
||||
DROP CONSTRAINT notification_discord_prefs_mode_check;
|
||||
ALTER TABLE public.notification_discord_prefs
|
||||
ADD CONSTRAINT notification_discord_prefs_mode_check
|
||||
CHECK (mode IN ('off', 'per_episode', 'daily_digest', 'per_episode_and_digest'));
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
-- Coerce combined-mode rows to per_episode before restoring the narrower
|
||||
-- constraint, so the rollback cannot fail on existing data.
|
||||
UPDATE public.notification_email_prefs
|
||||
SET mode = 'per_episode' WHERE mode = 'per_episode_and_digest';
|
||||
UPDATE public.notification_discord_prefs
|
||||
SET mode = 'per_episode' WHERE mode = 'per_episode_and_digest';
|
||||
|
||||
ALTER TABLE public.notification_email_prefs
|
||||
DROP CONSTRAINT notification_email_prefs_mode_check;
|
||||
ALTER TABLE public.notification_email_prefs
|
||||
ADD CONSTRAINT notification_email_prefs_mode_check
|
||||
CHECK (mode IN ('off', 'per_episode', 'daily_digest'));
|
||||
|
||||
ALTER TABLE public.notification_discord_prefs
|
||||
DROP CONSTRAINT notification_discord_prefs_mode_check;
|
||||
ALTER TABLE public.notification_discord_prefs
|
||||
ADD CONSTRAINT notification_discord_prefs_mode_check
|
||||
CHECK (mode IN ('off', 'per_episode', 'daily_digest'));
|
||||
-- +goose StatementEnd
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user