Files
silo-server/internal/notifications/server_channel_service.go

306 lines
10 KiB
Go

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
NotifyNewAudiobooks *bool
NotifyNewEbooks *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 gates delivery in the worker, not configuration. Admins
// can prepare and test destinations while delivery remains disabled.
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),
NotifyNewAudiobooks: boolOrDefault(input.NotifyNewAudiobooks, true),
NotifyNewEbooks: boolOrDefault(input.NotifyNewEbooks, 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.NotifyNewAudiobooks != nil {
ch.NotifyNewAudiobooks = *input.NotifyNewAudiobooks
}
if input.NotifyNewEbooks != nil {
ch.NotifyNewEbooks = *input.NotifyNewEbooks
}
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) {
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: one group per flat
// item kind plus an episode group, so a test post exercises every render
// path the channel can receive.
func sampleContentGroups() []ContentGroup {
groups := make([]ContentGroup, 0, len(flatItemKinds)+1)
for _, k := range flatItemKinds {
meta := ContentMeta{Title: "Silo Test " + capitalize(k.ItemType), Year: 2026}
if k.Kind != EventKindMovie {
meta.Author = "Test Author"
}
groups = append(groups, ContentGroup{
Kind: k.Kind,
LibraryID: 1,
ItemID: "test-" + k.Kind,
Meta: meta,
})
}
return append(groups, ContentGroup{
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)},
},
})
}
// capitalize upper-cases the first ASCII letter of a display noun.
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}