* 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>
151 lines
4.7 KiB
Go
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
|
|
}
|