Files
silo-server/internal/notifications/webhook_service.go
QuickandClaude Fable 5 3d2368aed7 feat(notifications): admin server channels broadcasting new content and request activity
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>
2026-06-11 23:04:35 -04:00

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
}