* 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>
165 lines
6.0 KiB
Go
165 lines
6.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/autoscan"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/plugins"
|
|
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
|
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
|
)
|
|
|
|
// autoscanQueuer is the scan-enqueue surface BuildAutoscanService needs; it is
|
|
// satisfied by *scanqueue.Service (autoscan.Queuer's concrete production impl).
|
|
type autoscanQueuer = autoscan.Queuer
|
|
|
|
// RequestIntegrationLookup adapts the Requests repository to the autoscan
|
|
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
|
|
// Requests integration to its base URL and api key (the repo decrypts the key
|
|
// on read, so this returns plaintext).
|
|
type RequestIntegrationLookup struct {
|
|
Repo *mediarequests.Repository
|
|
}
|
|
|
|
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
|
|
integration, err := l.Repo.GetIntegration(ctx, integrationID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
// A reused Requests connection must honor the integration's live state. The
|
|
// v1 poll gated on `WHERE ri.enabled = true`; here we surface a disabled or
|
|
// unconfigured (blank base_url) integration as an error so the engine turns
|
|
// it into a logged skip / RecordError rather than polling an unusable target.
|
|
if err := checkRequestIntegrationUsable(integrationID, integration.Enabled, integration.BaseURL); err != nil {
|
|
return "", "", err
|
|
}
|
|
return integration.BaseURL, integration.APIKeyRef, nil
|
|
}
|
|
|
|
// checkRequestIntegrationUsable returns a non-nil error when a linked Requests
|
|
// integration cannot be polled: it is disabled, or it has no base_url. Extracted
|
|
// as a pure function so the gating is unit-testable without a DB-backed repo.
|
|
func checkRequestIntegrationUsable(integrationID string, enabled bool, baseURL string) error {
|
|
if !enabled {
|
|
return fmt.Errorf("linked requests integration %q is disabled", integrationID)
|
|
}
|
|
if strings.TrimSpace(baseURL) == "" {
|
|
return fmt.Errorf("linked requests integration %q has no base_url configured", integrationID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PluginScanSourceAdapter adapts plugins.Service to autoscan.ScanSourceResolver.
|
|
// plugins.Service.ScanSourceClient returns the concrete
|
|
// *pluginhost.ScanSourceClient; that concrete type satisfies
|
|
// autoscan.PollChangesClient (it has the matching PollChanges method), so the
|
|
// adapter declares the exported interface as its return type and returns the
|
|
// concrete value (Go has no return-type covariance, so the method signature must
|
|
// name the interface exactly to satisfy ScanSourceResolver).
|
|
type PluginScanSourceAdapter struct {
|
|
Svc *plugins.Service
|
|
}
|
|
|
|
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
|
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
|
}
|
|
|
|
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
|
// enumerates.
|
|
const scanSourceCapabilityType = "scan_source.v1"
|
|
|
|
// PluginScanSourceLister adapts the plugin installation store to
|
|
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
|
|
// capability across ALL installed plugins, regardless of enabled state.
|
|
type PluginScanSourceLister struct {
|
|
Store *plugins.InstallationStore
|
|
}
|
|
|
|
func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan.DiscoveredSource, error) {
|
|
installations, err := l.Store.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []autoscan.DiscoveredSource
|
|
for _, inst := range installations {
|
|
caps, err := l.Store.ListCapabilities(ctx, inst.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, c := range caps {
|
|
if c == nil || c.Type != scanSourceCapabilityType {
|
|
continue
|
|
}
|
|
out = append(out, autoscan.DiscoveredSource{
|
|
PluginID: inst.PluginID,
|
|
CapabilityID: c.ID,
|
|
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
|
})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// scanSourceDisplayName derives a human-friendly label for a scan_source
|
|
// capability: the capability manifest's display_name when present, else the
|
|
// plugin id (with the capability id appended when it adds information).
|
|
func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
|
|
if c != nil && c.Metadata != nil {
|
|
if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" {
|
|
return strings.TrimSpace(name)
|
|
}
|
|
}
|
|
switch {
|
|
case pluginID != "" && c != nil && c.ID != "":
|
|
return pluginID + " / " + c.ID
|
|
case pluginID != "":
|
|
return pluginID
|
|
case c != nil:
|
|
return c.ID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// BuildAutoscanService wires the v2 autoscan engine from its concrete
|
|
// dependencies. Both the HTTP router (manual trigger) and the background poll
|
|
// task share this constructor so the adapter wiring lives in exactly one place.
|
|
// Credentials are now decrypted inline by the autoscan/requests repos, so there
|
|
// is no separate secret resolver to thread.
|
|
func BuildAutoscanService(
|
|
repo *autoscan.Repository,
|
|
pluginService *plugins.Service,
|
|
installationStore *plugins.InstallationStore,
|
|
requestsRepo *mediarequests.Repository,
|
|
folderRepo *catalog.FolderRepository,
|
|
queue autoscanQueuer,
|
|
redisClient *redis.Client,
|
|
) *autoscan.Service {
|
|
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
|
|
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
|
|
svc := autoscan.NewService(
|
|
repo,
|
|
provider,
|
|
connRes,
|
|
scantrigger.NewResolver(folderRepo),
|
|
queue,
|
|
autoscan.NewRedisSuppressor(redisClient),
|
|
autoscan.WithBuiltinSources(
|
|
PluginScanSourceLister{installationStore},
|
|
autoscan.BuiltinArrWebhookSource(),
|
|
),
|
|
)
|
|
// Wire the connection-test + rewrite-suggester deps: a (long-timeout)
|
|
// arr root-folder/status client and a Silo media-folder lister.
|
|
svc.SetSuggesterDeps(
|
|
autoscan.NewArrRootFolderClient(nil),
|
|
autoscan.NewCatalogFolderLister(folderRepo),
|
|
)
|
|
return svc
|
|
}
|