* 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>
205 lines
6.2 KiB
Go
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}}
|
|
}
|