c488f481ca
PicPeak POSTs lifecycle notifications to admin-configured URLs. Each delivery is signed HMAC-SHA256 in the X-PicPeak-Signature header. Verified end-to-end: 1/1 Playwright spec, 8/8 backend integration tests, full UI click-through via Chrome DevTools. Schema (migration 082) - webhooks: id, name, url, secret (plaintext — required to compute HMAC for every outbound POST), secret_preview, events[], active, filter, template, created_by, timestamps, last_success_at/last_failure_at. - webhook_deliveries: webhook_id (FK CASCADE), event_type, payload, attempt_count, status (pending|success|failed), response_status, response_body (truncated to 1KB), latency_ms, next_retry_at, last_error, created_at, completed_at. Composite index (status, next_retry_at) serves the worker's hot-path query. Service + worker - webhookService.fire(eventType, data) — non-throwing entry point used by lifecycle hooks. Looks up active webhooks subscribed to the event and applies their per-webhook filter (dot-path equality predicate) before enqueueing one webhook_deliveries row per match. Filter and template logic ship in this commit; admin surfaces in the follow-up. - webhookDeliveryWorker — setInterval(5s) poller; fetches up to 5 pending rows; per delivery: re-validates URL via networkValidation (DNS-rebinding mitigation, opt-out via WEBHOOK_ALLOW_PRIVATE_URLS), signs body with HMAC-SHA256, POSTs with 10s timeout, records outcome. Backoff schedule: 1m → 5m → 30m → 2h → 12h, max 5 attempts. Response body truncated to 1KB before storage. If a webhook has a template, the rendered string replaces the JSON envelope as the request body (signature is computed over the bytes actually sent). Lifecycle wiring - adminEvents.js POST /events → event.created (+ event.published when not draft); POST /:id/publish → event.published. - routes/events.js (legacy public POST) → event.created + event.published. - routes/v1/events.js (#322 API) → event.created + event.published on create, photo.uploaded on photo POST. - archiveService.archiveEvent() → event.archived. Per-photo photo.deleted intentionally NOT fired during cascade — receivers infer from event.archived to avoid flooding (issue spec). - expirationChecker.handleExpiredEvent() → event.expired BEFORE the cascading archive (so receivers see expired→archived in order). - adminPhotos.js — photo.uploaded on each batch row, photo.deleted on single + bulk delete. - photoProcessor.js — photo.uploaded for guest uploads + auto-import (covers all entry paths). - fileWatcher.js — photo.uploaded on add, photo.deleted on unlink (local mode only). Admin endpoints (mirrors adminApiTokens.js pattern) - /api/admin/webhooks: GET list, POST create (returns plaintext secret exactly once), GET :id, PUT :id, DELETE :id, POST :id/test (synthetic fire), GET :id/deliveries (paginated, filter by status), GET :id/deliveries/:deliveryId, POST :id/deliveries/:deliveryId/replay. Frontend - Settings → Webhooks tab (mirrors API Tokens layout): name + URL + event checkboxes + "Advanced" expander for filter (JSON) and template. Plaintext secret shown once on creation with a Copy button. Active/ Disabled toggle button per row. - /admin/webhooks/:id/deliveries — operational debug surface. Table with timestamp/event/status/attempts/HTTP/latency. Status filter chips (all/pending/success/failed). Row click → slide-over with payload + signature + response body. Replay button on failed rows. Send-test-event dialog. Auto-refresh every 10s. Dev infrastructure - dev/webhook-receiver/ — tiny node:alpine HTTP server (~100 LOC) that records every POST to an in-memory ring buffer. Exposes GET /requests for the E2E spec to assert deliveries landed with the right HMAC. Sibling pattern to MinIO. Reachable from the backend at http://webhook-receiver:8888 inside the picpeak network. Tests - backend/__tests__/integration/webhookDelivery.test.js (8/8) — signature verification, headers, retry/backoff, max-attempts → failed, response truncation, disabled-mid-flight, SSRF block, start/stop idempotency. - tests/e2e/webhooks-roundtrip.spec.ts (1/1) — create webhook → trigger event.published → assert receiver got POST with valid HMAC → visit deliveries page → row visible with status=success → API test event → API replay → disable webhook → assert no new delivery. Docs - README §"Webhooks" — event catalog, payload shape, HMAC verification in Node + Python + bash, retry semantics, SSRF protection. - .env.example — WEBHOOK_ALLOW_PRIVATE_URLS, WEBHOOK_DELIVERY_INTERVAL_MS, WEBHOOK_DELIVERY_CONCURRENCY, WEBHOOK_HTTP_TIMEOUT_MS, WEBHOOK_MAX_ATTEMPTS. Out of scope for v1 (per issue): webhook templates' code-eval (the ${dot.path} substitution that ships is pure string replacement, no expression engine — see follow-up commit), per-webhook rate limiting beyond the global concurrency cap, synchronous "ask before delete" webhooks. Spanning files - App.tsx pulls in this commit with both the AnalyticsBootstrap (#325 dedup) and the WebhookDeliveriesPage route registration. Splitting via git add -p was forfeit for sanity; the single 92-line diff is honest about both contributions. - adminEvents.js diff bundles the webhook fires AND the allow_presigned_download field plumbing (#328 follow-up). Same reasoning. - The new webhookService/Worker/adminWebhooks files include the filter and template logic from the follow-up — they were authored in one pass; splitting them post-hoc would have produced fragile partial files. The follow-up commit covers the migration and the UI for these.
240 lines
9.0 KiB
JavaScript
240 lines
9.0 KiB
JavaScript
// Worker reads WEBHOOK_ALLOW_PRIVATE_URLS at module-load. Set it BEFORE
|
|
// requiring the worker so the local-stub URLs (127.0.0.1:<random>) pass
|
|
// the SSRF check by default.
|
|
process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
|
|
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
|
|
|
|
const http = require('http');
|
|
const { db } = require('../../src/database/db');
|
|
const webhookService = require('../../src/services/webhookService');
|
|
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
|
|
|
|
// Local-only test stub: matches what dev/webhook-receiver/server.js does
|
|
// in the docker-compose flow but spun up inside the Jest process so the
|
|
// suite is self-contained.
|
|
function makeStub({ status = 200, delayMs = 0, bodyOverride = null } = {}) {
|
|
const requests = [];
|
|
const server = http.createServer(async (req, res) => {
|
|
const chunks = [];
|
|
for await (const c of req) chunks.push(c);
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
|
|
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
|
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
res.end(bodyOverride !== null ? bodyOverride : (status >= 200 && status < 300 ? 'ok' : 'forced'));
|
|
});
|
|
return new Promise((resolve) => {
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const port = server.address().port;
|
|
resolve({ url: `http://127.0.0.1:${port}/`, requests, close: () => new Promise((r) => server.close(r)) });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function insertWebhook(url, events = ['event.published'], extras = {}) {
|
|
// Tests need the WORKER to bypass SSRF on 127.0.0.1 stubs, but the
|
|
// route layer's allowlist check is bypassed here since we insert
|
|
// straight into the DB.
|
|
const { plaintext, preview } = webhookService.generateSecret();
|
|
const insert = await db('webhooks').insert({
|
|
name: extras.name || 'test',
|
|
url,
|
|
secret: plaintext,
|
|
secret_preview: preview,
|
|
events: JSON.stringify(events),
|
|
active: extras.active !== false,
|
|
created_by: 1,
|
|
}).returning('id');
|
|
const id = insert[0]?.id || insert[0];
|
|
return { id, secret: plaintext };
|
|
}
|
|
|
|
async function clearWebhooks() {
|
|
await db('webhook_deliveries').del();
|
|
await db('webhooks').del();
|
|
}
|
|
|
|
describe('webhook delivery worker (#327)', () => {
|
|
beforeAll(async () => {
|
|
// Schema is expected to already be applied by `npm run migrate`. We
|
|
// just verify the webhooks tables exist; if not, the test harness has
|
|
// missed running migration 082.
|
|
const ok = await db.schema.hasTable('webhooks');
|
|
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
stopWebhookDeliveryWorker();
|
|
await db.destroy();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await clearWebhooks();
|
|
});
|
|
|
|
test('signs the body with HMAC-SHA256 and the receiver can verify', async () => {
|
|
const stub = await makeStub({ status: 200 });
|
|
try {
|
|
const { id, secret } = await insertWebhook(stub.url);
|
|
await webhookService.fire('event.published', { event: { id: 1, slug: 'sig-test' } });
|
|
await __test.tick();
|
|
|
|
expect(stub.requests).toHaveLength(1);
|
|
const got = stub.requests[0];
|
|
const sig = got.headers['x-picpeak-signature'];
|
|
expect(sig).toBeTruthy();
|
|
// Receiver-side verification using the SAME helper we ship in the README.
|
|
expect(webhookService.verifySignature(secret, got.body, sig)).toBe(true);
|
|
// Tampering must fail.
|
|
expect(webhookService.verifySignature(secret, got.body + 'x', sig)).toBe(false);
|
|
|
|
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
|
expect(row.status).toBe('success');
|
|
expect(row.attempt_count).toBe(1);
|
|
expect(row.response_status).toBe(200);
|
|
expect(row.latency_ms).toBeGreaterThanOrEqual(0);
|
|
} finally {
|
|
await stub.close();
|
|
}
|
|
});
|
|
|
|
test('headers include event type and a unique delivery id', async () => {
|
|
const stub = await makeStub({ status: 200 });
|
|
try {
|
|
await insertWebhook(stub.url, ['photo.uploaded']);
|
|
await webhookService.fire('photo.uploaded', { photo: { id: 7 } });
|
|
await __test.tick();
|
|
|
|
const got = stub.requests[0];
|
|
expect(got.headers['x-picpeak-event']).toBe('photo.uploaded');
|
|
expect(got.headers['x-picpeak-delivery']).toBeTruthy();
|
|
expect(got.headers['user-agent']).toMatch(/PicPeak-Webhooks/);
|
|
} finally {
|
|
await stub.close();
|
|
}
|
|
});
|
|
|
|
test('on 5xx, schedules a retry with exponential backoff and stays pending', async () => {
|
|
const stub = await makeStub({ status: 500 });
|
|
try {
|
|
const { id } = await insertWebhook(stub.url);
|
|
await webhookService.fire('event.published', { event: { id: 2 } });
|
|
await __test.tick();
|
|
|
|
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
|
expect(row.status).toBe('pending');
|
|
expect(row.attempt_count).toBe(1);
|
|
expect(row.response_status).toBe(500);
|
|
// BACKOFF_MS[0] = 60s; next_retry_at should be ~60s in the future.
|
|
const dueIn = new Date(row.next_retry_at).getTime() - Date.now();
|
|
expect(dueIn).toBeGreaterThan(50_000);
|
|
expect(dueIn).toBeLessThan(70_000);
|
|
} finally {
|
|
await stub.close();
|
|
}
|
|
});
|
|
|
|
test('after MAX_ATTEMPTS failures, status flips to failed and the row is closed', async () => {
|
|
const stub = await makeStub({ status: 500 });
|
|
try {
|
|
const { id } = await insertWebhook(stub.url);
|
|
// Pre-seed a delivery already at attempt_count = 4 so a single tick
|
|
// takes it to 5 → failed (avoids waiting through backoffs).
|
|
await db('webhook_deliveries').insert({
|
|
webhook_id: id,
|
|
event_type: 'event.published',
|
|
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
|
attempt_count: 4,
|
|
status: 'pending',
|
|
next_retry_at: new Date(),
|
|
created_at: new Date(),
|
|
});
|
|
await __test.tick();
|
|
|
|
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
|
expect(row.status).toBe('failed');
|
|
expect(row.attempt_count).toBe(5);
|
|
expect(row.completed_at).toBeTruthy();
|
|
expect(row.next_retry_at).toBeNull();
|
|
} finally {
|
|
await stub.close();
|
|
}
|
|
});
|
|
|
|
test('truncates response body to 1KB before storing', async () => {
|
|
const big = 'x'.repeat(5000);
|
|
const stub = await makeStub({ status: 200, bodyOverride: big });
|
|
try {
|
|
const { id } = await insertWebhook(stub.url);
|
|
await webhookService.fire('event.published', { event: {} });
|
|
await __test.tick();
|
|
|
|
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
|
expect(row.status).toBe('success');
|
|
expect(Buffer.byteLength(row.response_body || '', 'utf8')).toBeLessThanOrEqual(1024);
|
|
} finally {
|
|
await stub.close();
|
|
}
|
|
});
|
|
|
|
test('does not deliver to disabled webhooks (post-mortem state captured)', async () => {
|
|
const stub = await makeStub({ status: 200 });
|
|
try {
|
|
const { id } = await insertWebhook(stub.url, ['event.published'], { active: false });
|
|
// fire enqueues regardless of active state at fire-time, but we
|
|
// disabled BEFORE firing so nothing is enqueued. Direct insert to
|
|
// exercise the worker's mid-flight disable check:
|
|
await db('webhook_deliveries').insert({
|
|
webhook_id: id,
|
|
event_type: 'event.published',
|
|
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
|
attempt_count: 0,
|
|
status: 'pending',
|
|
next_retry_at: new Date(),
|
|
created_at: new Date(),
|
|
});
|
|
await __test.tick();
|
|
|
|
expect(stub.requests).toHaveLength(0);
|
|
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
|
expect(row.status).toBe('failed');
|
|
expect(row.last_error).toMatch(/disabled/i);
|
|
} finally {
|
|
await stub.close();
|
|
}
|
|
});
|
|
|
|
test('rejects loopback URLs when WEBHOOK_ALLOW_PRIVATE_URLS=false', async () => {
|
|
__test.setAllowPrivateUrls(false);
|
|
try {
|
|
const { id } = await insertWebhook('http://127.0.0.1:9/');
|
|
await db('webhook_deliveries').insert({
|
|
webhook_id: id,
|
|
event_type: 'event.published',
|
|
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
|
attempt_count: 0,
|
|
status: 'pending',
|
|
next_retry_at: new Date(),
|
|
created_at: new Date(),
|
|
});
|
|
await __test.tick();
|
|
|
|
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
|
expect(row.status).toBe('failed');
|
|
expect(row.last_error).toMatch(/private|internal/i);
|
|
} finally {
|
|
__test.setAllowPrivateUrls(true);
|
|
}
|
|
});
|
|
|
|
test('worker can be started + stopped without leaking timers', async () => {
|
|
startWebhookDeliveryWorker();
|
|
startWebhookDeliveryWorker(); // idempotent
|
|
stopWebhookDeliveryWorker();
|
|
stopWebhookDeliveryWorker(); // idempotent
|
|
// If timers leaked the test runner would warn after force-exit; assertion
|
|
// is just "no throw".
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|