Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).
- Extend release_events with a kind discriminator and add a movie
availability spine (movie_availability + kind-keyed
notification_content_seed_state; first full scan seeds silently so
upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
grouping, per-channel backoff, and auto-disable; request events post
best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
SSRF guard, embed limits, HMAC signing; shared type/name validation
extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
Channels section in the notifications admin settings UI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
367 lines
12 KiB
Go
367 lines
12 KiB
Go
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
|
|
}
|