Files
silo-server/internal/autoscan/arrwebhook/parse.go
d68e70bb47 feat(autoscan): Sonarr/Radarr webhook intake without arr API keys (#353)
* docs(autoscan): add arr webhook intake spec and implementation plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autoscan): add webhook intake schema migration

Adds delivery_mode to autoscan_sources, the autoscan_webhook_endpoints
table, and delivery_mode/provider_event_type on autoscan_events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autoscan): add built-in arr-webhook source identity

Host-discovered scan-source entry so webhook-mode sources need no
plugin installation; composite lister appends it to plugin discovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autoscan): persist delivery mode, webhook endpoints, event metadata

Sources carry delivery_mode; autoscan_webhook_endpoints CRUD with
SHA-256 token lookup and AAD-bound encrypted redisplay; events record
delivery_mode/provider_event_type; CreateEvent gains SkipRunningCheck
so webhook deliveries are never dropped by the poll exclusion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autoscan): share the consume path and add webhook IngestChanges

Extracts consumeSourceChanges from PollOnce (marker semantics
preserved, existing poll tests unchanged); PollOnce skips webhook
sources; IngestChanges feeds deliveries through the shared pipeline
without markers and without the running-event exclusion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autoscan): add Sonarr/Radarr webhook payload parser

Host-side arrwebhook package: provider inference, import/rename/delete
path extraction with vanished-path-friendly previous paths, subtree
fallback, exact-path dedupe, and no-op unknown events. Fixture-backed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autoscan): add public webhook delivery route and admin endpoint management

Public POST /api/v1/autoscan/webhooks/{token} with per-IP rate
limiting, 256KiB body cap, 202-for-noop semantics, and token/body kept
out of logs; admin create/rotate/delete endpoint routes; source
responses carry delivery mode + webhook status/URL; create/update
validate delivery mode against source identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): add webhook delivery mode to Autoscan admin UI

Webhook sources get a generate/copy/rotate webhook URL section,
provider selector, delivery status, and a connection-free Add-source
flow; activity rows badge webhook deliveries with the arr event type.
Path rewrites stay editable in both modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): redact secret path params from request and activity logs

The request logger and activity-log middleware recorded raw URLs, so
bearer credentials in secret path segments (autoscan webhook {token},
webhook-sync {secret}) were persisted to app logs and activity_log.
Redact the secret segment via the chi route params in both sinks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autoscan): make webhook delivery reliable

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:13:31 -04:00

205 lines
6.2 KiB
Go

// Package arrwebhook parses Sonarr/Radarr webhook payloads into autoscan
// changes. It is deliberately host-side and handler-free: input is the raw
// request body, output is the provider/event classification plus the file or
// subtree paths to feed autoscan.Service.IngestChanges. Payload-derived
// content never appears in returned error messages, so they are safe to log
// and to echo in HTTP responses.
package arrwebhook
import (
"encoding/json"
"errors"
"strings"
"github.com/Silo-Server/silo-server/internal/autoscan"
)
// Provider names accepted by Parse and stored in source_config.webhook_provider.
const (
ProviderAuto = "auto"
ProviderSonarr = "sonarr"
ProviderRadarr = "radarr"
)
// ErrMalformedPayload reports a body that is not valid JSON or has no
// eventType. Handlers map it to 400.
var ErrMalformedPayload = errors.New("arrwebhook: malformed payload")
// ErrUnknownProvider reports a payload whose provider could not be inferred in
// auto mode. Handlers map it to 400.
var ErrUnknownProvider = errors.New("arrwebhook: cannot infer provider from payload")
// ParsedWebhook is one classified delivery. Unsupported event types parse
// successfully with no changes (callers no-op them): arr adds event types over
// time and an unknown one must not make the webhook look unhealthy.
type ParsedWebhook struct {
Provider string
EventType string
Test bool
Changes []autoscan.Change
}
// fileRef is the common shape of arr webhook file resources (episodeFile,
// movieFile, and the renamed* variants).
type fileRef struct {
Path string `json:"path"`
PreviousPath string `json:"previousPath"`
}
type payload struct {
EventType string `json:"eventType"`
Series *struct {
Path string `json:"path"`
} `json:"series"`
EpisodeFile *fileRef `json:"episodeFile"`
EpisodeFiles []fileRef `json:"episodeFiles"`
RenamedEpisodeFiles []fileRef `json:"renamedEpisodeFiles"`
Movie *struct {
Path string `json:"path"`
FolderPath string `json:"folderPath"`
} `json:"movie"`
MovieFile *fileRef `json:"movieFile"`
MovieFiles []fileRef `json:"movieFiles"`
RenamedMovieFiles []fileRef `json:"renamedMovieFiles"`
// Download/import payloads include the files replaced by an upgrade. They
// must be reconciled as vanished paths even when a separate "file delete for
// upgrade" notification is not enabled in arr.
DeletedFiles []fileRef `json:"deletedFiles"`
}
// importEventTypes are the events that carry newly imported files.
var importEventTypes = map[string]bool{
"Download": true,
"Import": true,
"Upgrade": true,
"DownloadComplete": true,
}
// deleteEventTypes are the events that carry a removed file path.
var deleteEventTypes = map[string]bool{
"EpisodeFileDelete": true,
"MovieFileDelete": true,
}
// Parse classifies a webhook body. provider is "sonarr", "radarr", or "auto"
// (empty means auto); in auto mode the provider is inferred from the payload's
// series/movie shape. Inference failure is an error only for non-test events
// that would otherwise produce work.
func Parse(provider string, body []byte) (ParsedWebhook, error) {
var p payload
if err := json.Unmarshal(body, &p); err != nil {
return ParsedWebhook{}, ErrMalformedPayload
}
eventType := strings.TrimSpace(p.EventType)
if eventType == "" {
return ParsedWebhook{}, ErrMalformedPayload
}
out := ParsedWebhook{EventType: eventType, Provider: resolveProvider(provider, p)}
if strings.EqualFold(eventType, "Test") {
out.Test = true
return out, nil
}
switch {
case importEventTypes[eventType]:
out.Changes = filePaths(p, false)
if len(out.Changes) == 0 {
out.Changes = subtreeFallback(p)
}
case eventType == "Rename":
out.Changes = filePaths(p, true)
case deleteEventTypes[eventType]:
out.Changes = filePaths(p, false)
default:
// Unsupported event type: valid delivery, nothing to do.
return out, nil
}
if out.Provider == "" {
return ParsedWebhook{}, ErrUnknownProvider
}
return out, nil
}
// resolveProvider returns the explicit provider when set, otherwise infers
// sonarr/radarr from the payload shape ("" when inference fails).
func resolveProvider(provider string, p payload) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case ProviderSonarr:
return ProviderSonarr
case ProviderRadarr:
return ProviderRadarr
}
switch {
case p.Series != nil || p.EpisodeFile != nil || len(p.EpisodeFiles) > 0 || len(p.RenamedEpisodeFiles) > 0:
return ProviderSonarr
case p.Movie != nil || p.MovieFile != nil || len(p.MovieFiles) > 0 || len(p.RenamedMovieFiles) > 0:
return ProviderRadarr
}
return ""
}
// filePaths collects every file path in the payload as file-scope changes,
// deduped in order. includePrevious additionally collects previousPath values
// (rename events): the previous path may no longer exist, which the host's
// resolve pipeline already handles via its vanished-path fallback.
func filePaths(p payload, includePrevious bool) []autoscan.Change {
var refs []fileRef
if p.EpisodeFile != nil {
refs = append(refs, *p.EpisodeFile)
}
refs = append(refs, p.EpisodeFiles...)
refs = append(refs, p.RenamedEpisodeFiles...)
if p.MovieFile != nil {
refs = append(refs, *p.MovieFile)
}
refs = append(refs, p.MovieFiles...)
refs = append(refs, p.RenamedMovieFiles...)
refs = append(refs, p.DeletedFiles...)
seen := make(map[string]struct{})
var changes []autoscan.Change
add := func(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
if _, dup := seen[path]; dup {
return
}
seen[path] = struct{}{}
changes = append(changes, autoscan.Change{SourcePath: path, Scope: autoscan.ChangeScopeFile})
}
for _, ref := range refs {
add(ref.Path)
if includePrevious {
add(ref.PreviousPath)
}
}
return changes
}
// subtreeFallback maps an import event with no file details to the series or
// movie folder as one subtree-scope change.
func subtreeFallback(p payload) []autoscan.Change {
path := ""
switch {
case p.Series != nil:
path = p.Series.Path
case p.Movie != nil:
path = p.Movie.FolderPath
if strings.TrimSpace(path) == "" {
path = p.Movie.Path
}
}
path = strings.TrimSpace(path)
if path == "" {
return nil
}
return []autoscan.Change{{SourcePath: path, Scope: autoscan.ChangeScopeSubtree}}
}