Files
silo-server/internal/autoscan/webhook_delivery.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

151 lines
4.7 KiB
Go

package autoscan
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
const webhookDeliveryLease = 5 * time.Minute
func postgresInterval(d time.Duration) string {
return fmt.Sprintf("%f seconds", d.Seconds())
}
func scanWebhookDelivery(row interface{ Scan(...any) error }) (WebhookDelivery, error) {
var (
delivery WebhookDelivery
changes []byte
)
if err := row.Scan(
&delivery.ID,
&delivery.SourceID,
&delivery.ProviderEventType,
&changes,
&delivery.ReceivedAt,
&delivery.AttemptCount,
&delivery.LockedBy,
); err != nil {
return WebhookDelivery{}, err
}
if err := json.Unmarshal(changes, &delivery.Changes); err != nil {
return WebhookDelivery{}, fmt.Errorf("decode autoscan webhook delivery changes: %w", err)
}
return delivery, nil
}
// CreateWebhookDelivery durably accepts a delivery and leases it to the caller
// for an immediate ingest attempt. If the caller exits before finalizing it, a
// retry worker can reclaim it after webhookDeliveryLease.
func (r *Repository) CreateWebhookDelivery(ctx context.Context, in ChangeIngest) (WebhookDelivery, error) {
changes, err := json.Marshal(in.Changes)
if err != nil {
return WebhookDelivery{}, fmt.Errorf("encode autoscan webhook delivery changes: %w", err)
}
receivedAt := in.ReceivedAt
if receivedAt.IsZero() {
receivedAt = time.Now()
}
lockedBy := uuid.NewString()
row := r.pool.QueryRow(ctx, `
INSERT INTO autoscan_webhook_deliveries (
source_id, provider_event_type, changes, received_at,
attempt_count, next_attempt_at, locked_at, locked_by
)
VALUES ($1, $2, $3, $4, 1, now(), now(), $5)
RETURNING id, source_id, provider_event_type, changes, received_at,
attempt_count, locked_by`,
in.SourceID, in.ProviderEventType, changes, receivedAt, lockedBy)
delivery, err := scanWebhookDelivery(row)
if err != nil {
return WebhookDelivery{}, fmt.Errorf("create autoscan webhook delivery: %w", err)
}
return delivery, nil
}
// ClaimWebhookDeliveries leases due or abandoned deliveries to one worker.
// FOR UPDATE SKIP LOCKED keeps concurrent nodes from ingesting the same row.
func (r *Repository) ClaimWebhookDeliveries(ctx context.Context, workerID string, limit int) ([]WebhookDelivery, error) {
if limit <= 0 {
return []WebhookDelivery{}, nil
}
rows, err := r.pool.Query(ctx, `
WITH due AS (
SELECT id
FROM autoscan_webhook_deliveries
WHERE next_attempt_at <= now()
AND (locked_at IS NULL OR locked_at < now() - $2::interval)
ORDER BY next_attempt_at ASC, id ASC
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE autoscan_webhook_deliveries d
SET attempt_count = d.attempt_count + 1,
locked_at = now(),
locked_by = $3,
updated_at = now()
FROM due
WHERE d.id = due.id
RETURNING d.id, d.source_id, d.provider_event_type, d.changes,
d.received_at, d.attempt_count, d.locked_by`,
limit, postgresInterval(webhookDeliveryLease), workerID)
if err != nil {
return nil, fmt.Errorf("claim autoscan webhook deliveries: %w", err)
}
defer rows.Close()
deliveries := make([]WebhookDelivery, 0, limit)
for rows.Next() {
delivery, err := scanWebhookDelivery(rows)
if err != nil {
return nil, err
}
deliveries = append(deliveries, delivery)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate autoscan webhook deliveries: %w", err)
}
return deliveries, nil
}
// CompleteWebhookDelivery removes a successfully consumed delivery. The lease
// owner guard prevents stale workers from deleting a row another node reclaimed.
func (r *Repository) CompleteWebhookDelivery(ctx context.Context, id int64, lockedBy string) error {
tag, err := r.pool.Exec(ctx, `
DELETE FROM autoscan_webhook_deliveries
WHERE id = $1 AND locked_by = $2`, id, lockedBy)
if err != nil {
return fmt.Errorf("complete autoscan webhook delivery: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: webhook delivery %d lease", ErrNotFound, id)
}
return nil
}
// RetryWebhookDelivery releases a failed delivery back to the durable queue
// after a bounded delay. The lease owner guard makes late workers harmless.
func (r *Repository) RetryWebhookDelivery(ctx context.Context, id int64, lockedBy string, delay time.Duration, msg string) error {
if delay < 0 {
delay = 0
}
tag, err := r.pool.Exec(ctx, `
UPDATE autoscan_webhook_deliveries
SET next_attempt_at = now() + $3::interval,
locked_at = NULL,
locked_by = '',
last_error = $4,
updated_at = now()
WHERE id = $1 AND locked_by = $2`,
id, lockedBy, postgresInterval(delay), truncateUTF8(msg, maxLastErrorLen))
if err != nil {
return fmt.Errorf("retry autoscan webhook delivery: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: webhook delivery %d lease", ErrNotFound, id)
}
return nil
}