feat: outbound webhooks for event/photo lifecycle (#327)
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.
This commit is contained in:
@@ -0,0 +1,239 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* #327 — outbound webhooks (push API) for the event/photo lifecycle.
|
||||||
|
*
|
||||||
|
* Two tables:
|
||||||
|
* webhooks — admin-managed subscriptions (URL + events + secret)
|
||||||
|
* webhook_deliveries — single source of truth for the delivery worker
|
||||||
|
* (audit log + retry queue in one).
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function up(knex) {
|
||||||
|
if (!(await knex.schema.hasTable('webhooks'))) {
|
||||||
|
await knex.schema.createTable('webhooks', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name', 100).notNullable();
|
||||||
|
// Validated via networkValidation.validateExternalUrl on create + per
|
||||||
|
// delivery (DNS-rebinding mitigation).
|
||||||
|
table.string('url', 2048).notNullable();
|
||||||
|
// Plaintext signing secret (`whsec_<random>`). Stored unencrypted
|
||||||
|
// because we need to recompute HMAC-SHA256 over every outbound body
|
||||||
|
// — a hash would make the secret unrecoverable. Same posture as
|
||||||
|
// SMTP passwords stored in app_settings; protect the DB. The
|
||||||
|
// plaintext is also returned to the admin once on create so they can
|
||||||
|
// configure the receiver to verify signatures.
|
||||||
|
table.string('secret', 100).notNullable();
|
||||||
|
// First 8 chars of the secret for the admin UI so operators can
|
||||||
|
// tell which webhook is which without revealing the full secret.
|
||||||
|
table.string('secret_preview', 16).nullable();
|
||||||
|
// JSON array of subscribed event types
|
||||||
|
// (e.g. ["event.published","photo.uploaded"]).
|
||||||
|
table.jsonb('events').notNullable().defaultTo('[]');
|
||||||
|
table.boolean('active').notNullable().defaultTo(true);
|
||||||
|
table.integer('created_by').notNullable()
|
||||||
|
.references('id').inTable('admin_users').onDelete('CASCADE');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('last_success_at').nullable();
|
||||||
|
table.timestamp('last_failure_at').nullable();
|
||||||
|
// Index for the delivery worker's "find subscriptions for this event"
|
||||||
|
// query — small set, but keeps the lookup constant-time as it grows.
|
||||||
|
table.index('active', 'webhooks_active_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await knex.schema.hasTable('webhook_deliveries'))) {
|
||||||
|
await knex.schema.createTable('webhook_deliveries', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('webhook_id').notNullable()
|
||||||
|
.references('id').inTable('webhooks').onDelete('CASCADE');
|
||||||
|
table.string('event_type', 64).notNullable();
|
||||||
|
// Full signed payload (the JSON body that was POSTed).
|
||||||
|
table.jsonb('payload').notNullable();
|
||||||
|
table.integer('attempt_count').notNullable().defaultTo(0);
|
||||||
|
// pending → success | failed. pending rows with next_retry_at <= NOW()
|
||||||
|
// are picked up by the worker.
|
||||||
|
table.string('status', 16).notNullable().defaultTo('pending');
|
||||||
|
table.integer('response_status').nullable();
|
||||||
|
// Truncated to 1KB before storage so a verbose receiver can't blow
|
||||||
|
// up the row size.
|
||||||
|
table.text('response_body').nullable();
|
||||||
|
table.text('last_error').nullable();
|
||||||
|
table.integer('latency_ms').nullable();
|
||||||
|
table.timestamp('next_retry_at').nullable();
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('completed_at').nullable();
|
||||||
|
// Worker hot-path query: WHERE status='pending' AND next_retry_at <= NOW()
|
||||||
|
// ORDER BY next_retry_at LIMIT N. This composite index serves it directly.
|
||||||
|
table.index(['status', 'next_retry_at'], 'webhook_deliveries_status_retry_idx');
|
||||||
|
table.index('webhook_id', 'webhook_deliveries_webhook_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function down(knex) {
|
||||||
|
if (await knex.schema.hasTable('webhook_deliveries')) {
|
||||||
|
await knex.schema.dropTable('webhook_deliveries');
|
||||||
|
}
|
||||||
|
if (await knex.schema.hasTable('webhooks')) {
|
||||||
|
await knex.schema.dropTable('webhooks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -298,6 +298,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
body('enable_devtools_protection').optional().isBoolean(),
|
body('enable_devtools_protection').optional().isBoolean(),
|
||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
body('watermark_text').optional().trim(),
|
body('watermark_text').optional().trim(),
|
||||||
|
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
|
||||||
|
// Bypasses watermarks; admin must enable knowingly.
|
||||||
|
body('allow_presigned_download').optional().isBoolean(),
|
||||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||||
// Hero logo settings
|
// Hero logo settings
|
||||||
body('hero_logo_visible').optional().isBoolean(),
|
body('hero_logo_visible').optional().isBoolean(),
|
||||||
@@ -344,6 +347,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
enable_devtools_protection: enableDevtoolsProtectionInput,
|
enable_devtools_protection: enableDevtoolsProtectionInput,
|
||||||
watermark_downloads = false,
|
watermark_downloads = false,
|
||||||
watermark_text = null,
|
watermark_text = null,
|
||||||
|
allow_presigned_download = false,
|
||||||
require_password: requirePasswordInput,
|
require_password: requirePasswordInput,
|
||||||
// Feedback settings
|
// Feedback settings
|
||||||
feedback_enabled = false,
|
feedback_enabled = false,
|
||||||
@@ -557,6 +561,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
|
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
|
||||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||||
watermark_text,
|
watermark_text,
|
||||||
|
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
|
||||||
require_password: formatBoolean(requirePassword),
|
require_password: formatBoolean(requirePassword),
|
||||||
css_template_id: css_template_id || null,
|
css_template_id: css_template_id || null,
|
||||||
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
|
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
|
||||||
@@ -603,6 +608,15 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fire event.created webhook (#327). If the event is being published
|
||||||
|
// immediately (not a draft), event.published also fires below.
|
||||||
|
try {
|
||||||
|
const webhookService = require('../services/webhookService');
|
||||||
|
await webhookService.fire('event.created', {
|
||||||
|
event: { id: eventId, slug, event_name, event_type, event_date, is_draft: parseBooleanInput(is_draft, true) },
|
||||||
|
});
|
||||||
|
} catch (e) { /* webhookService.fire never throws but be defensive */ }
|
||||||
|
|
||||||
// Queue creation email (only if there is a recipient and event is not a draft)
|
// Queue creation email (only if there is a recipient and event is not a draft)
|
||||||
// Language detection is handled by email processor
|
// Language detection is handled by email processor
|
||||||
const isDraft = parseBooleanInput(is_draft, true);
|
const isDraft = parseBooleanInput(is_draft, true);
|
||||||
@@ -640,6 +654,18 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire event.published when the event is created NOT as a draft. The
|
||||||
|
// separate /publish endpoint fires it for the draft → live transition;
|
||||||
|
// this covers the "create-and-publish in one shot" path.
|
||||||
|
if (!isDraft) {
|
||||||
|
try {
|
||||||
|
const webhookService = require('../services/webhookService');
|
||||||
|
await webhookService.fire('event.published', {
|
||||||
|
event: { id: eventId, slug, event_name, share_url: shareUrl },
|
||||||
|
});
|
||||||
|
} catch (e) { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: eventId,
|
id: eventId,
|
||||||
slug,
|
slug,
|
||||||
@@ -875,6 +901,15 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
|
|||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fire event.published webhook (#327) — draft → live transition.
|
||||||
|
try {
|
||||||
|
const webhookService = require('../services/webhookService');
|
||||||
|
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||||
|
await webhookService.fire('event.published', {
|
||||||
|
event: { id: parseInt(id, 10), slug: event.slug, event_name: event.event_name, share_url: shareUrl },
|
||||||
|
});
|
||||||
|
} catch (e) { /* non-fatal */ }
|
||||||
|
|
||||||
res.json({ message: 'Event published successfully', is_draft: false });
|
res.json({ message: 'Event published successfully', is_draft: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error publishing event:', { error: error.message });
|
logger.error('Error publishing event:', { error: error.message });
|
||||||
@@ -912,6 +947,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
|||||||
body('disable_right_click').optional().isBoolean(),
|
body('disable_right_click').optional().isBoolean(),
|
||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
body('watermark_text').optional().trim(),
|
body('watermark_text').optional().trim(),
|
||||||
|
body('allow_presigned_download').optional().isBoolean(),
|
||||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||||
body('external_path').optional({ nullable: true }).isString().trim(),
|
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||||
body('require_password').optional().isBoolean(),
|
body('require_password').optional().isBoolean(),
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/**
|
||||||
|
* Admin endpoints for managing outbound webhooks (#327). Mirrors
|
||||||
|
* adminApiTokens.js — same permission gates, same "secret shown once"
|
||||||
|
* pattern.
|
||||||
|
*
|
||||||
|
* Routes mounted under /api/admin/webhooks:
|
||||||
|
* GET / — list
|
||||||
|
* POST / — create (returns plaintext secret once)
|
||||||
|
* GET /:id — detail (no secret)
|
||||||
|
* PUT /:id — update name/url/events/active
|
||||||
|
* DELETE /:id — delete (cascades to deliveries)
|
||||||
|
* POST /:id/test — fire a synthetic delivery now
|
||||||
|
* GET /:id/deliveries — list deliveries (paginated, filter)
|
||||||
|
* GET /:id/deliveries/:deliveryId — delivery detail (payload+response)
|
||||||
|
* POST /:id/deliveries/:deliveryId/replay — re-enqueue a delivery
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { body, query, validationResult } = require('express-validator');
|
||||||
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { validateExternalUrl } = require('../utils/networkValidation');
|
||||||
|
const webhookService = require('../services/webhookService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const ALLOW_PRIVATE_URLS = process.env.WEBHOOK_ALLOW_PRIVATE_URLS === 'true';
|
||||||
|
|
||||||
|
function publicWebhook(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
url: row.url,
|
||||||
|
events: typeof row.events === 'string' ? safeJson(row.events, []) : (row.events || []),
|
||||||
|
active: row.active,
|
||||||
|
secret_preview: row.secret_preview,
|
||||||
|
filter: typeof row.filter === 'string' ? safeJson(row.filter, {}) : (row.filter || {}),
|
||||||
|
template: row.template || null,
|
||||||
|
created_by: row.created_by,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
last_success_at: row.last_success_at,
|
||||||
|
last_failure_at: row.last_failure_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(s, fallback) {
|
||||||
|
try { return JSON.parse(s); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List ────────────────────────────────────────────────────────────────
|
||||||
|
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await db('webhooks')
|
||||||
|
.leftJoin('admin_users', 'admin_users.id', 'webhooks.created_by')
|
||||||
|
.select(
|
||||||
|
'webhooks.*',
|
||||||
|
'admin_users.username as owner_username'
|
||||||
|
)
|
||||||
|
.orderBy('webhooks.created_at', 'desc');
|
||||||
|
res.json(rows.map((r) => ({
|
||||||
|
...publicWebhook(r),
|
||||||
|
owner_username: r.owner_username,
|
||||||
|
})));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('webhooks list failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to list webhooks' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Create ──────────────────────────────────────────────────────────────
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
[
|
||||||
|
body('name').isString().trim().isLength({ min: 1, max: 100 }),
|
||||||
|
body('url').isString().isLength({ max: 2048 }).custom((url) => {
|
||||||
|
if (ALLOW_PRIVATE_URLS) return true;
|
||||||
|
const check = validateExternalUrl(url);
|
||||||
|
if (!check.valid) throw new Error(check.error);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
body('events').isArray({ min: 1 }).custom((arr) => {
|
||||||
|
const ok = arr.every((e) => webhookService.EVENT_TYPES.includes(e));
|
||||||
|
if (!ok) throw new Error(`events must be a subset of: ${webhookService.EVENT_TYPES.join(', ')}`);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
body('active').optional().isBoolean(),
|
||||||
|
body('filter').optional().custom((v) => {
|
||||||
|
if (v == null) return true;
|
||||||
|
if (typeof v !== 'object' || Array.isArray(v)) {
|
||||||
|
throw new Error('filter must be an object of dot-path → value pairs');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
body('template').optional({ nullable: true }).custom((v) => {
|
||||||
|
const check = webhookService.validateTemplate(v);
|
||||||
|
if (!check.valid) throw new Error(check.error);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||||
|
|
||||||
|
const { name, url, events, active = true, filter, template } = req.body;
|
||||||
|
const { plaintext, preview } = webhookService.generateSecret();
|
||||||
|
|
||||||
|
const insertResult = await db('webhooks').insert({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
secret: plaintext,
|
||||||
|
secret_preview: preview,
|
||||||
|
events: JSON.stringify(events),
|
||||||
|
active,
|
||||||
|
filter: JSON.stringify(filter || {}),
|
||||||
|
template: template || null,
|
||||||
|
created_by: req.admin.id,
|
||||||
|
}).returning('id');
|
||||||
|
const id = insertResult[0]?.id || insertResult[0];
|
||||||
|
|
||||||
|
await logActivity('webhook_created', { name, events }, null, {
|
||||||
|
type: 'admin', id: req.admin.id, name: req.admin.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = await db('webhooks').where({ id }).first();
|
||||||
|
res.status(201).json({
|
||||||
|
...publicWebhook(row),
|
||||||
|
secret: plaintext,
|
||||||
|
notice: 'Save this signing secret now — it will not be shown again.',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('webhooks create failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to create webhook' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Detail ──────────────────────────────────────────────────────────────
|
||||||
|
router.get('/:id', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await db('webhooks').where({ id: req.params.id }).first();
|
||||||
|
if (!row) return res.status(404).json({ error: 'Webhook not found' });
|
||||||
|
res.json(publicWebhook(row));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('webhooks detail failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to load webhook' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Update ──────────────────────────────────────────────────────────────
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
[
|
||||||
|
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
|
||||||
|
body('url').optional().isString().isLength({ max: 2048 }).custom((url) => {
|
||||||
|
if (ALLOW_PRIVATE_URLS) return true;
|
||||||
|
const check = validateExternalUrl(url);
|
||||||
|
if (!check.valid) throw new Error(check.error);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
body('events').optional().isArray({ min: 1 }).custom((arr) => {
|
||||||
|
const ok = arr.every((e) => webhookService.EVENT_TYPES.includes(e));
|
||||||
|
if (!ok) throw new Error(`events must be a subset of: ${webhookService.EVENT_TYPES.join(', ')}`);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
body('active').optional().isBoolean(),
|
||||||
|
body('filter').optional().custom((v) => {
|
||||||
|
if (v == null) return true;
|
||||||
|
if (typeof v !== 'object' || Array.isArray(v)) {
|
||||||
|
throw new Error('filter must be an object of dot-path → value pairs');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
body('template').optional({ nullable: true }).custom((v) => {
|
||||||
|
const check = webhookService.validateTemplate(v);
|
||||||
|
if (!check.valid) throw new Error(check.error);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||||
|
|
||||||
|
const row = await db('webhooks').where({ id: req.params.id }).first();
|
||||||
|
if (!row) return res.status(404).json({ error: 'Webhook not found' });
|
||||||
|
|
||||||
|
const updates = { updated_at: new Date() };
|
||||||
|
if ('name' in req.body) updates.name = req.body.name;
|
||||||
|
if ('url' in req.body) updates.url = req.body.url;
|
||||||
|
if ('events' in req.body) updates.events = JSON.stringify(req.body.events);
|
||||||
|
if ('active' in req.body) updates.active = req.body.active;
|
||||||
|
if ('filter' in req.body) updates.filter = JSON.stringify(req.body.filter || {});
|
||||||
|
if ('template' in req.body) updates.template = req.body.template || null;
|
||||||
|
|
||||||
|
await db('webhooks').where({ id: req.params.id }).update(updates);
|
||||||
|
const updated = await db('webhooks').where({ id: req.params.id }).first();
|
||||||
|
|
||||||
|
await logActivity('webhook_updated', { changes: Object.keys(updates) }, null, {
|
||||||
|
type: 'admin', id: req.admin.id, name: req.admin.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(publicWebhook(updated));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('webhooks update failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to update webhook' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Delete ──────────────────────────────────────────────────────────────
|
||||||
|
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await db('webhooks').where({ id: req.params.id }).first();
|
||||||
|
if (!row) return res.status(404).json({ error: 'Webhook not found' });
|
||||||
|
await db('webhooks').where({ id: req.params.id }).delete();
|
||||||
|
await logActivity('webhook_deleted', { name: row.name }, null, {
|
||||||
|
type: 'admin', id: req.admin.id, name: req.admin.username,
|
||||||
|
});
|
||||||
|
res.json({ id: Number(req.params.id), deleted: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('webhooks delete failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to delete webhook' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Send test event ─────────────────────────────────────────────────────
|
||||||
|
router.post(
|
||||||
|
'/:id/test',
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
[body('event_type').optional().isIn(webhookService.EVENT_TYPES)],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||||
|
|
||||||
|
const row = await db('webhooks').where({ id: req.params.id }).first();
|
||||||
|
if (!row) return res.status(404).json({ error: 'Webhook not found' });
|
||||||
|
if (!row.active) return res.status(400).json({ error: 'Webhook is disabled' });
|
||||||
|
|
||||||
|
const eventType = req.body.event_type || (() => {
|
||||||
|
const subscribed = typeof row.events === 'string' ? safeJson(row.events, []) : (row.events || []);
|
||||||
|
return subscribed[0] || 'event.published';
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Fire a synthetic event WITHOUT writing to webhooks table — the test
|
||||||
|
// bypasses subscription matching by inserting a delivery directly.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const deliveryId = crypto.randomUUID();
|
||||||
|
const payload = {
|
||||||
|
id: deliveryId,
|
||||||
|
type: eventType,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
data: { test: true, fired_by: req.admin.username, webhook_id: row.id },
|
||||||
|
};
|
||||||
|
await db('webhook_deliveries').insert({
|
||||||
|
webhook_id: row.id,
|
||||||
|
event_type: eventType,
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
attempt_count: 0,
|
||||||
|
status: 'pending',
|
||||||
|
next_retry_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(202).json({ enqueued: true, event_type: eventType });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('webhook test failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to enqueue test event' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── List deliveries ─────────────────────────────────────────────────────
|
||||||
|
router.get(
|
||||||
|
'/:id/deliveries',
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('settings.view'),
|
||||||
|
[
|
||||||
|
query('status').optional().isIn(['pending', 'success', 'failed']),
|
||||||
|
query('page').optional().isInt({ min: 1 }),
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||||
|
|
||||||
|
const webhookId = req.params.id;
|
||||||
|
const exists = await db('webhooks').where({ id: webhookId }).first();
|
||||||
|
if (!exists) return res.status(404).json({ error: 'Webhook not found' });
|
||||||
|
|
||||||
|
const page = parseInt(req.query.page || '1', 10);
|
||||||
|
const limit = parseInt(req.query.limit || '25', 10);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let q = db('webhook_deliveries').where({ webhook_id: webhookId });
|
||||||
|
if (req.query.status) q = q.where({ status: req.query.status });
|
||||||
|
|
||||||
|
const totalRow = await q.clone().count('id as count').first();
|
||||||
|
const total = parseInt(totalRow?.count || 0, 10);
|
||||||
|
|
||||||
|
const rows = await q
|
||||||
|
.select(
|
||||||
|
'id', 'event_type', 'attempt_count', 'status', 'response_status',
|
||||||
|
'latency_ms', 'next_retry_at', 'created_at', 'completed_at', 'last_error'
|
||||||
|
)
|
||||||
|
.orderBy('created_at', 'desc')
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
res.json({ deliveries: rows, pagination: { page, limit, total } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('deliveries list failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to list deliveries' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Delivery detail ─────────────────────────────────────────────────────
|
||||||
|
router.get(
|
||||||
|
'/:id/deliveries/:deliveryId',
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('settings.view'),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await db('webhook_deliveries')
|
||||||
|
.where({ id: req.params.deliveryId, webhook_id: req.params.id })
|
||||||
|
.first();
|
||||||
|
if (!row) return res.status(404).json({ error: 'Delivery not found' });
|
||||||
|
res.json({
|
||||||
|
...row,
|
||||||
|
payload: typeof row.payload === 'string' ? safeJson(row.payload, row.payload) : row.payload,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('delivery detail failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to load delivery' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Replay ──────────────────────────────────────────────────────────────
|
||||||
|
router.post(
|
||||||
|
'/:id/deliveries/:deliveryId/replay',
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await db('webhook_deliveries')
|
||||||
|
.where({ id: req.params.deliveryId, webhook_id: req.params.id })
|
||||||
|
.first();
|
||||||
|
if (!row) return res.status(404).json({ error: 'Delivery not found' });
|
||||||
|
|
||||||
|
// Re-enqueue: copy the original payload + event_type into a new row
|
||||||
|
// marked pending. Preserves the audit log of the original attempt.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const newPayload = (() => {
|
||||||
|
const obj = typeof row.payload === 'string' ? safeJson(row.payload, {}) : row.payload || {};
|
||||||
|
// Replays get a fresh delivery id but keep the event payload data.
|
||||||
|
return JSON.stringify({ ...obj, id: crypto.randomUUID(), replayed_from: row.id });
|
||||||
|
})();
|
||||||
|
const insertResult = await db('webhook_deliveries').insert({
|
||||||
|
webhook_id: row.webhook_id,
|
||||||
|
event_type: row.event_type,
|
||||||
|
payload: newPayload,
|
||||||
|
attempt_count: 0,
|
||||||
|
status: 'pending',
|
||||||
|
next_retry_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
}).returning('id');
|
||||||
|
const newId = insertResult[0]?.id || insertResult[0];
|
||||||
|
res.status(202).json({ enqueued: true, original_id: row.id, replay_id: newId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('delivery replay failed', { error: err.message });
|
||||||
|
res.status(500).json({ error: 'Failed to replay delivery' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -190,6 +190,18 @@ router.post('/', adminAuth, [
|
|||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Webhook lifecycle (#327). Legacy public endpoint — events go live
|
||||||
|
// immediately so created + published fire together.
|
||||||
|
try {
|
||||||
|
const webhookService = require('../services/webhookService');
|
||||||
|
await webhookService.fire('event.created', {
|
||||||
|
event: { id: eventId, slug, event_name, event_type, event_date, share_url: shareUrl },
|
||||||
|
});
|
||||||
|
await webhookService.fire('event.published', {
|
||||||
|
event: { id: eventId, slug, event_name, share_url: shareUrl },
|
||||||
|
});
|
||||||
|
} catch (e) { /* non-fatal */ }
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: eventId,
|
id: eventId,
|
||||||
slug,
|
slug,
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ async function handleExpiredEvent(event) {
|
|||||||
// Mark as inactive
|
// Mark as inactive
|
||||||
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
|
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
|
||||||
|
|
||||||
|
// Fire event.expired BEFORE the cascading archive call so receivers
|
||||||
|
// get the lifecycle in order (expired → archived).
|
||||||
|
try {
|
||||||
|
const webhookService = require('./webhookService');
|
||||||
|
await webhookService.fire('event.expired', {
|
||||||
|
event: { id: event.id, slug: event.slug, event_name: event.event_name, expires_at: event.expires_at },
|
||||||
|
});
|
||||||
|
} catch (e) { /* non-fatal */ }
|
||||||
|
|
||||||
// Queue expiration emails
|
// Queue expiration emails
|
||||||
const recipientEmail = event.customer_email || event.host_email;
|
const recipientEmail = event.customer_email || event.host_email;
|
||||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { signPayload, renderTemplate } = require('./webhookService');
|
||||||
|
const { validateExternalUrl } = require('../utils/networkValidation');
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_DELIVERY_INTERVAL_MS || '5000', 10);
|
||||||
|
const CONCURRENCY = parseInt(process.env.WEBHOOK_DELIVERY_CONCURRENCY || '5', 10);
|
||||||
|
const HTTP_TIMEOUT_MS = parseInt(process.env.WEBHOOK_HTTP_TIMEOUT_MS || '10000', 10);
|
||||||
|
const MAX_ATTEMPTS = parseInt(process.env.WEBHOOK_MAX_ATTEMPTS || '5', 10);
|
||||||
|
const RESPONSE_TRUNCATE_BYTES = 1024;
|
||||||
|
// Mutable so tests can flip it without juggling require.cache; reads the
|
||||||
|
// env var at module load for the production code path.
|
||||||
|
let allowPrivateUrls = process.env.WEBHOOK_ALLOW_PRIVATE_URLS === 'true';
|
||||||
|
const SIGNATURE_HEADER = 'X-PicPeak-Signature';
|
||||||
|
const EVENT_HEADER = 'X-PicPeak-Event';
|
||||||
|
const DELIVERY_HEADER = 'X-PicPeak-Delivery';
|
||||||
|
|
||||||
|
// Backoff schedule per the issue spec — index = attempt that just failed.
|
||||||
|
// attempt_count after the failure becomes (failedAttempt + 1); we look up
|
||||||
|
// the delay using the *new* attempt count to schedule the next try.
|
||||||
|
// attempt 1 fails → wait 1m
|
||||||
|
// attempt 2 fails → wait 5m
|
||||||
|
// attempt 3 fails → wait 30m
|
||||||
|
// attempt 4 fails → wait 2h
|
||||||
|
// attempt 5 fails → wait 12h THEN give up (max 5 attempts total)
|
||||||
|
const BACKOFF_MS = [
|
||||||
|
60_000, // 1 min
|
||||||
|
5 * 60_000, // 5 min
|
||||||
|
30 * 60_000, // 30 min
|
||||||
|
2 * 60 * 60_000, // 2 h
|
||||||
|
12 * 60 * 60_000, // 12 h (only used when MAX_ATTEMPTS extended past 5)
|
||||||
|
];
|
||||||
|
|
||||||
|
let intervalHandle = null;
|
||||||
|
let stopped = false;
|
||||||
|
// Tracks deliveries currently being processed in this tick — guards
|
||||||
|
// against the same row being claimed twice if a tick takes longer than
|
||||||
|
// POLL_INTERVAL_MS.
|
||||||
|
const inFlight = new Set();
|
||||||
|
|
||||||
|
function truncate(str, bytes) {
|
||||||
|
if (str == null) return null;
|
||||||
|
const buf = Buffer.from(String(str), 'utf8');
|
||||||
|
if (buf.length <= bytes) return buf.toString('utf8');
|
||||||
|
return buf.subarray(0, bytes).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPending(limit) {
|
||||||
|
// Skip rows already in-flight from a previous tick that's still running.
|
||||||
|
const excludeIds = Array.from(inFlight);
|
||||||
|
let q = db('webhook_deliveries')
|
||||||
|
.where('status', 'pending')
|
||||||
|
.where('next_retry_at', '<=', new Date())
|
||||||
|
.orderBy('next_retry_at', 'asc')
|
||||||
|
.limit(limit);
|
||||||
|
if (excludeIds.length > 0) {
|
||||||
|
q = q.whereNotIn('id', excludeIds);
|
||||||
|
}
|
||||||
|
return q.select('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverOne(row) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const webhook = await db('webhooks').where({ id: row.webhook_id }).first();
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
// Webhook was deleted while a delivery was pending. Mark failed and move on.
|
||||||
|
await db('webhook_deliveries')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
last_error: 'webhook subscription no longer exists',
|
||||||
|
completed_at: new Date(),
|
||||||
|
attempt_count: row.attempt_count + 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhook.active) {
|
||||||
|
// Subscription disabled mid-flight. Don't abandon — leave as failed
|
||||||
|
// so the deliveries page reflects the reality.
|
||||||
|
await db('webhook_deliveries')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
last_error: 'webhook is disabled',
|
||||||
|
completed_at: new Date(),
|
||||||
|
attempt_count: row.attempt_count + 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-validate URL per delivery — DNS-rebinding mitigation. Admin can opt
|
||||||
|
// out via WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
|
||||||
|
if (!allowPrivateUrls) {
|
||||||
|
const urlCheck = validateExternalUrl(webhook.url);
|
||||||
|
if (!urlCheck.valid) {
|
||||||
|
await markFailedFinal(row, `URL rejected: ${urlCheck.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelopeBody = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload);
|
||||||
|
const envelopeObj = (() => {
|
||||||
|
try { return JSON.parse(envelopeBody); } catch { return {}; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Per-webhook template (#327 follow-up). If set + valid, replaces the
|
||||||
|
// default JSON envelope as the request body. Signature is computed over
|
||||||
|
// the BODY ACTUALLY SENT, so receivers verify whatever they receive.
|
||||||
|
let rawBody = envelopeBody;
|
||||||
|
let contentType = 'application/json';
|
||||||
|
if (webhook.template) {
|
||||||
|
const rendered = renderTemplate(webhook.template, envelopeObj);
|
||||||
|
if (rendered != null) {
|
||||||
|
rawBody = rendered;
|
||||||
|
// Best-effort content-type detection: if it parses as JSON, keep
|
||||||
|
// application/json; otherwise send as text/plain.
|
||||||
|
try { JSON.parse(rendered); } catch { contentType = 'text/plain; charset=utf-8'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const signature = signPayload(webhook.secret, rawBody);
|
||||||
|
const deliveryId = envelopeObj?.id || String(row.id);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
let networkError;
|
||||||
|
try {
|
||||||
|
response = await axios.post(webhook.url, rawBody, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
[SIGNATURE_HEADER]: signature,
|
||||||
|
[EVENT_HEADER]: row.event_type,
|
||||||
|
[DELIVERY_HEADER]: deliveryId,
|
||||||
|
'User-Agent': 'PicPeak-Webhooks/1.0',
|
||||||
|
},
|
||||||
|
timeout: HTTP_TIMEOUT_MS,
|
||||||
|
// Don't throw on non-2xx; we handle status manually.
|
||||||
|
validateStatus: () => true,
|
||||||
|
// Don't follow redirects — security + receivers should give us the
|
||||||
|
// final URL up front.
|
||||||
|
maxRedirects: 0,
|
||||||
|
// Cap response body so a chatty receiver can't OOM us before truncation.
|
||||||
|
maxContentLength: 10 * 1024,
|
||||||
|
maxBodyLength: rawBody.length + 1024,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
networkError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latency = Date.now() - startedAt;
|
||||||
|
const newAttempt = row.attempt_count + 1;
|
||||||
|
|
||||||
|
if (response && response.status >= 200 && response.status < 300) {
|
||||||
|
await db('webhook_deliveries')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({
|
||||||
|
status: 'success',
|
||||||
|
response_status: response.status,
|
||||||
|
response_body: truncate(stringifyBody(response.data), RESPONSE_TRUNCATE_BYTES),
|
||||||
|
latency_ms: latency,
|
||||||
|
attempt_count: newAttempt,
|
||||||
|
completed_at: new Date(),
|
||||||
|
next_retry_at: null,
|
||||||
|
});
|
||||||
|
await db('webhooks').where({ id: webhook.id }).update({ last_success_at: new Date() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure path — schedule retry or give up.
|
||||||
|
const errorMsg = networkError
|
||||||
|
? `network error: ${networkError.code || networkError.message}`
|
||||||
|
: `non-2xx status: ${response?.status}`;
|
||||||
|
|
||||||
|
if (newAttempt >= MAX_ATTEMPTS) {
|
||||||
|
await db('webhook_deliveries')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
response_status: response?.status || null,
|
||||||
|
response_body: response ? truncate(stringifyBody(response.data), RESPONSE_TRUNCATE_BYTES) : null,
|
||||||
|
last_error: errorMsg,
|
||||||
|
latency_ms: latency,
|
||||||
|
attempt_count: newAttempt,
|
||||||
|
completed_at: new Date(),
|
||||||
|
next_retry_at: null,
|
||||||
|
});
|
||||||
|
await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backoff = BACKOFF_MS[Math.min(newAttempt - 1, BACKOFF_MS.length - 1)];
|
||||||
|
await db('webhook_deliveries')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({
|
||||||
|
status: 'pending',
|
||||||
|
response_status: response?.status || null,
|
||||||
|
response_body: response ? truncate(stringifyBody(response.data), RESPONSE_TRUNCATE_BYTES) : null,
|
||||||
|
last_error: errorMsg,
|
||||||
|
latency_ms: latency,
|
||||||
|
attempt_count: newAttempt,
|
||||||
|
next_retry_at: new Date(Date.now() + backoff),
|
||||||
|
});
|
||||||
|
await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markFailedFinal(row, reason) {
|
||||||
|
await db('webhook_deliveries')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
last_error: reason,
|
||||||
|
attempt_count: row.attempt_count + 1,
|
||||||
|
completed_at: new Date(),
|
||||||
|
next_retry_at: null,
|
||||||
|
});
|
||||||
|
await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyBody(data) {
|
||||||
|
if (data == null) return null;
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
||||||
|
try { return JSON.stringify(data); } catch { return String(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (stopped) return;
|
||||||
|
try {
|
||||||
|
const slots = Math.max(0, CONCURRENCY - inFlight.size);
|
||||||
|
if (slots === 0) return;
|
||||||
|
const rows = await fetchPending(slots);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
rows.forEach((r) => inFlight.add(r.id));
|
||||||
|
await Promise.allSettled(
|
||||||
|
rows.map((r) =>
|
||||||
|
deliverOne(r)
|
||||||
|
.catch((err) => logger.error(`[webhookWorker] delivery ${r.id} crashed: ${err.message}`))
|
||||||
|
.finally(() => inFlight.delete(r.id))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[webhookWorker] tick failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWebhookDeliveryWorker() {
|
||||||
|
if (intervalHandle) return; // idempotent
|
||||||
|
stopped = false;
|
||||||
|
intervalHandle = setInterval(tick, POLL_INTERVAL_MS);
|
||||||
|
logger.info(
|
||||||
|
`[webhookWorker] started — interval=${POLL_INTERVAL_MS}ms, concurrency=${CONCURRENCY}, ` +
|
||||||
|
`max_attempts=${MAX_ATTEMPTS}, allow_private=${allowPrivateUrls}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWebhookDeliveryWorker() {
|
||||||
|
stopped = true;
|
||||||
|
if (intervalHandle) {
|
||||||
|
clearInterval(intervalHandle);
|
||||||
|
intervalHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
startWebhookDeliveryWorker,
|
||||||
|
stopWebhookDeliveryWorker,
|
||||||
|
// exported for tests
|
||||||
|
__test: {
|
||||||
|
tick,
|
||||||
|
BACKOFF_MS,
|
||||||
|
SIGNATURE_HEADER,
|
||||||
|
EVENT_HEADER,
|
||||||
|
DELIVERY_HEADER,
|
||||||
|
setAllowPrivateUrls(value) { allowPrivateUrls = !!value; },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const SECRET_PREFIX = 'whsec_';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event types PicPeak emits. Keep this in sync with the README catalog and
|
||||||
|
* the receiver-side type unions in any SDK we publish later. Consumers of
|
||||||
|
* `fire(eventType, ...)` MUST use one of these strings — the worker will
|
||||||
|
* silently drop unknown types so a typo can't 500 a request handler.
|
||||||
|
*/
|
||||||
|
const EVENT_TYPES = Object.freeze([
|
||||||
|
'event.created',
|
||||||
|
'event.published',
|
||||||
|
'event.archived',
|
||||||
|
'event.expired',
|
||||||
|
'photo.uploaded',
|
||||||
|
'photo.deleted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function generateSecret() {
|
||||||
|
const random = crypto.randomBytes(24).toString('base64url'); // ~32 chars
|
||||||
|
const plaintext = `${SECRET_PREFIX}${random}`;
|
||||||
|
return {
|
||||||
|
plaintext,
|
||||||
|
preview: random.slice(0, 8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a payload with the webhook's secret. Used by the delivery worker;
|
||||||
|
* exported for unit tests of receiver-side verification snippets.
|
||||||
|
*/
|
||||||
|
function signPayload(secret, rawBody) {
|
||||||
|
return crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a dot-path on an object (e.g. "data.event.event_type") with no
|
||||||
|
* eval. Returns undefined for missing segments — never throws.
|
||||||
|
*/
|
||||||
|
function getByPath(obj, dotPath) {
|
||||||
|
if (!dotPath || typeof dotPath !== 'string') return undefined;
|
||||||
|
return dotPath.split('.').reduce((acc, key) => {
|
||||||
|
if (acc == null || typeof acc !== 'object') return undefined;
|
||||||
|
return acc[key];
|
||||||
|
}, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a webhook's filter against an outgoing payload. The filter is a
|
||||||
|
* flat object of dot-path → expected value pairs:
|
||||||
|
* { "data.event.event_type": "wedding" }
|
||||||
|
* { "type": "event.published", "data.event.id": 42 }
|
||||||
|
*
|
||||||
|
* All keys must match (logical AND). Equality is `===` after JSON-style
|
||||||
|
* coercion: numbers as numbers, booleans as booleans. Empty filter
|
||||||
|
* matches everything (back-compat).
|
||||||
|
*/
|
||||||
|
function payloadMatchesFilter(filter, payload) {
|
||||||
|
if (!filter || typeof filter !== 'object') return true;
|
||||||
|
const keys = Object.keys(filter);
|
||||||
|
if (keys.length === 0) return true;
|
||||||
|
for (const key of keys) {
|
||||||
|
const expected = filter[key];
|
||||||
|
const actual = getByPath(payload, key);
|
||||||
|
if (Array.isArray(expected)) {
|
||||||
|
// Array means "any of"
|
||||||
|
if (!expected.includes(actual)) return false;
|
||||||
|
} else if (actual !== expected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a webhook template by substituting ${dot.path} expressions with
|
||||||
|
* values from the payload. NO eval, NO logic — pure string substitution.
|
||||||
|
* Caps output at 64KB; bails out and returns null if exceeded so the
|
||||||
|
* delivery worker can fall back to the default envelope.
|
||||||
|
*/
|
||||||
|
function renderTemplate(template, payload) {
|
||||||
|
if (template == null || template === '') return null;
|
||||||
|
if (typeof template !== 'string') return null;
|
||||||
|
const MAX_OUTPUT = 64 * 1024;
|
||||||
|
const MAX_SUBSTITUTIONS = 64;
|
||||||
|
let count = 0;
|
||||||
|
const rendered = template.replace(/\$\{([^}]+)\}/g, (_match, expr) => {
|
||||||
|
count += 1;
|
||||||
|
if (count > MAX_SUBSTITUTIONS) return '';
|
||||||
|
const value = getByPath(payload, expr.trim());
|
||||||
|
if (value == null) return '';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try { return JSON.stringify(value); } catch { return ''; }
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
if (Buffer.byteLength(rendered, 'utf8') > MAX_OUTPUT) return null;
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a template at create-time so admins get immediate feedback
|
||||||
|
* instead of silent delivery failures. Returns { valid, error? }.
|
||||||
|
*/
|
||||||
|
function validateTemplate(template) {
|
||||||
|
if (template == null || template === '') return { valid: true };
|
||||||
|
if (typeof template !== 'string') return { valid: false, error: 'template must be a string' };
|
||||||
|
if (Buffer.byteLength(template, 'utf8') > 8192) return { valid: false, error: 'template exceeds 8KB' };
|
||||||
|
// Reject unbalanced ${ that would silently swallow content at render.
|
||||||
|
const opens = (template.match(/\$\{/g) || []).length;
|
||||||
|
const closes = (template.match(/\}/g) || []).length;
|
||||||
|
// Count is approximate (every } is counted, even non-matching ones).
|
||||||
|
// We require at least as many } as ${, which is necessary but not sufficient.
|
||||||
|
if (opens > closes) return { valid: false, error: 'template has unbalanced ${ — every ${ needs a matching }' };
|
||||||
|
if (opens > 64) return { valid: false, error: 'template exceeds 64 substitutions' };
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time signature comparison helper for receivers and tests.
|
||||||
|
* Exposed so the same primitive backs verification examples in the README.
|
||||||
|
*/
|
||||||
|
function verifySignature(secret, rawBody, signature) {
|
||||||
|
const expected = signPayload(secret, rawBody);
|
||||||
|
const a = Buffer.from(expected, 'hex');
|
||||||
|
let b;
|
||||||
|
try {
|
||||||
|
b = Buffer.from(signature || '', 'hex');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a webhook delivery for every active webhook subscribed to
|
||||||
|
* `eventType`. NEVER throws — webhook failures must not break the
|
||||||
|
* lifecycle handler that emitted the event. Worker handles HTTP delivery.
|
||||||
|
*
|
||||||
|
* @param {string} eventType — one of EVENT_TYPES
|
||||||
|
* @param {object} data — opaque payload that gets nested under .data in
|
||||||
|
* the outbound JSON body
|
||||||
|
*/
|
||||||
|
async function fire(eventType, data) {
|
||||||
|
if (!EVENT_TYPES.includes(eventType)) {
|
||||||
|
logger.warn(`[webhookService] dropping unknown event type: ${eventType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// jsonb @> ARRAY check across vendors: both pg and sqlite drivers we
|
||||||
|
// support handle a simple WHERE on `active=true` then a runtime filter
|
||||||
|
// on the events array; doing the array filter here keeps the query
|
||||||
|
// portable.
|
||||||
|
const candidates = await db('webhooks').where({ active: true });
|
||||||
|
const subscribed = candidates.filter((w) => {
|
||||||
|
const evts = Array.isArray(w.events)
|
||||||
|
? w.events
|
||||||
|
: (() => { try { return JSON.parse(w.events) || []; } catch { return []; } })();
|
||||||
|
return evts.includes(eventType);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscribed.length === 0) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const buildEnvelope = (deliveryUuid) => ({
|
||||||
|
id: deliveryUuid,
|
||||||
|
type: eventType,
|
||||||
|
created_at: now.toISOString(),
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const w of subscribed) {
|
||||||
|
const deliveryUuid = crypto.randomUUID();
|
||||||
|
const envelope = buildEnvelope(deliveryUuid);
|
||||||
|
|
||||||
|
// Filter (#327 follow-up): per-webhook predicate evaluated against
|
||||||
|
// the payload. Skip insertion when it doesn't match.
|
||||||
|
const filter = parseJsonField(w.filter, {});
|
||||||
|
if (!payloadMatchesFilter(filter, envelope)) continue;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
webhook_id: w.id,
|
||||||
|
event_type: eventType,
|
||||||
|
payload: JSON.stringify(envelope),
|
||||||
|
attempt_count: 0,
|
||||||
|
status: 'pending',
|
||||||
|
next_retry_at: now,
|
||||||
|
created_at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
await db('webhook_deliveries').insert(rows);
|
||||||
|
} catch (err) {
|
||||||
|
// Log but don't throw — caller's transaction has already committed
|
||||||
|
// by the time we get here, and we don't want to mask the success.
|
||||||
|
logger.error(`[webhookService.fire] failed to enqueue ${eventType}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonField(value, fallback) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
try { return JSON.parse(value) ?? fallback; } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fire,
|
||||||
|
generateSecret,
|
||||||
|
signPayload,
|
||||||
|
verifySignature,
|
||||||
|
payloadMatchesFilter,
|
||||||
|
renderTemplate,
|
||||||
|
validateTemplate,
|
||||||
|
getByPath,
|
||||||
|
EVENT_TYPES,
|
||||||
|
SECRET_PREFIX,
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server.js ./
|
||||||
|
EXPOSE 8888
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Tiny dev-only webhook receiver. Logs every request as one JSON line per
|
||||||
|
// hit so the E2E spec can poll the log file (or hit GET /requests to read
|
||||||
|
// from memory). Holds the last 200 requests in a ring buffer.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
// POST / — accept any webhook; records and returns 200
|
||||||
|
// GET /requests — returns the ring buffer as JSON
|
||||||
|
// POST /reset — clear the ring buffer
|
||||||
|
// GET /health — 200 ok
|
||||||
|
//
|
||||||
|
// Configurable response status via FORCE_STATUS env (e.g. 500 to test retries).
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '8888', 10);
|
||||||
|
const RING_SIZE = parseInt(process.env.RING_SIZE || '200', 10);
|
||||||
|
const FORCE_STATUS = parseInt(process.env.FORCE_STATUS || '200', 10);
|
||||||
|
|
||||||
|
const ring = [];
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let total = 0;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > 1024 * 1024) {
|
||||||
|
reject(new Error('payload too large'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (req.method === 'GET' && req.url === '/health') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('ok');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/requests') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(ring));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/reset') {
|
||||||
|
ring.length = 0;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('cleared');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat every other request as a webhook delivery to record.
|
||||||
|
let body = '';
|
||||||
|
try {
|
||||||
|
body = await readBody(req);
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(413, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
headers: req.headers,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
ring.push(entry);
|
||||||
|
if (ring.length > RING_SIZE) ring.shift();
|
||||||
|
|
||||||
|
// Log a single line so docker logs gives a quick readable trace.
|
||||||
|
process.stdout.write(
|
||||||
|
`[webhook-receiver] ${req.method} ${req.url} sig=${
|
||||||
|
req.headers['x-picpeak-signature'] || '-'
|
||||||
|
} type=${(() => {
|
||||||
|
try { return JSON.parse(body)?.type || '-'; } catch { return '-'; }
|
||||||
|
})()}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.writeHead(FORCE_STATUS, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(FORCE_STATUS >= 200 && FORCE_STATUS < 300 ? 'ok' : 'forced-failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
process.stdout.write(`webhook-receiver listening on :${PORT}\n`);
|
||||||
|
});
|
||||||
+39
-53
@@ -26,14 +26,15 @@ import {
|
|||||||
BackupManagement,
|
BackupManagement,
|
||||||
CMSPage,
|
CMSPage,
|
||||||
UserManagementPage,
|
UserManagementPage,
|
||||||
EventTypesPage
|
EventTypesPage,
|
||||||
|
WebhookDeliveriesPage
|
||||||
} from './pages/admin';
|
} from './pages/admin';
|
||||||
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
|
||||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||||
import { getApiBaseUrl } from './utils/url';
|
import { usePublicSettings } from './hooks/usePublicSettings';
|
||||||
|
|
||||||
// Create a client
|
// Create a client
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -45,6 +46,40 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bootstraps Umami analytics from /public/settings. Lives inside QueryClientProvider
|
||||||
|
// so it shares the public-settings cache with every other consumer of usePublicSettings.
|
||||||
|
function AnalyticsBootstrap() {
|
||||||
|
const { data: settings, isError } = usePublicSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings && !isError) return;
|
||||||
|
|
||||||
|
const envUmamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||||
|
const envUmamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||||
|
|
||||||
|
if (settings?.umami_enabled && settings.umami_url && settings.umami_website_id) {
|
||||||
|
analyticsService.initialize({
|
||||||
|
websiteId: settings.umami_website_id,
|
||||||
|
hostUrl: settings.umami_url,
|
||||||
|
autoTrack: true,
|
||||||
|
doNotTrack: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envUmamiUrl && envUmamiWebsiteId && (isError || settings?.enable_analytics !== false)) {
|
||||||
|
analyticsService.initialize({
|
||||||
|
websiteId: envUmamiWebsiteId,
|
||||||
|
hostUrl: envUmamiUrl,
|
||||||
|
autoTrack: true,
|
||||||
|
doNotTrack: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings, isError]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Track dark mode for toast theming
|
// Track dark mode for toast theming
|
||||||
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
|
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
|
||||||
@@ -57,60 +92,10 @@ function App() {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize Umami Analytics based on settings
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeAnalytics = async () => {
|
|
||||||
try {
|
|
||||||
// Fetch public settings to get Umami configuration
|
|
||||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
|
||||||
const settings = await response.json();
|
|
||||||
|
|
||||||
// Check if Umami is enabled and configured in backend settings
|
|
||||||
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
|
|
||||||
// Use backend configuration
|
|
||||||
analyticsService.initialize({
|
|
||||||
websiteId: settings.umami_website_id,
|
|
||||||
hostUrl: settings.umami_url,
|
|
||||||
autoTrack: true,
|
|
||||||
doNotTrack: true
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fall back to environment variables if backend not configured
|
|
||||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
|
||||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
|
||||||
|
|
||||||
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
|
|
||||||
analyticsService.initialize({
|
|
||||||
websiteId: umamiWebsiteId,
|
|
||||||
hostUrl: umamiUrl,
|
|
||||||
autoTrack: true,
|
|
||||||
doNotTrack: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch settings for analytics:', error);
|
|
||||||
// Fall back to environment variables on error
|
|
||||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
|
||||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
|
||||||
|
|
||||||
if (umamiUrl && umamiWebsiteId) {
|
|
||||||
analyticsService.initialize({
|
|
||||||
websiteId: umamiWebsiteId,
|
|
||||||
hostUrl: umamiUrl,
|
|
||||||
autoTrack: true,
|
|
||||||
doNotTrack: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeAnalytics();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AnalyticsBootstrap />
|
||||||
<MaintenanceProvider>
|
<MaintenanceProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<GlobalThemeProvider>
|
<GlobalThemeProvider>
|
||||||
@@ -148,6 +133,7 @@ function App() {
|
|||||||
<Route path="branding" element={<BrandingPage />} />
|
<Route path="branding" element={<BrandingPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="event-types" element={<EventTypesPage />} />
|
<Route path="event-types" element={<EventTypesPage />} />
|
||||||
|
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
||||||
<Route path="backup" element={<BackupManagement />} />
|
<Route path="backup" element={<BackupManagement />} />
|
||||||
<Route path="cms" element={<CMSPage />} />
|
<Route path="cms" element={<CMSPage />} />
|
||||||
<Route path="users" element={<UserManagementPage />} />
|
<Route path="users" element={<UserManagementPage />} />
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ export { StylingTab } from './tabs/StylingTab';
|
|||||||
export { SEOTab } from './tabs/SEOTab';
|
export { SEOTab } from './tabs/SEOTab';
|
||||||
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
||||||
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
||||||
|
export { WebhooksTab } from './tabs/WebhooksTab';
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Webhook as WebhookIcon, Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
|
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||||
|
import { api } from '../../../config/api';
|
||||||
|
|
||||||
|
const WEBHOOK_EVENT_TYPES = [
|
||||||
|
'event.created',
|
||||||
|
'event.published',
|
||||||
|
'event.archived',
|
||||||
|
'event.expired',
|
||||||
|
'photo.uploaded',
|
||||||
|
'photo.deleted',
|
||||||
|
] as const;
|
||||||
|
type WebhookEventType = typeof WEBHOOK_EVENT_TYPES[number];
|
||||||
|
|
||||||
|
interface WebhookRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: WebhookEventType[];
|
||||||
|
active: boolean;
|
||||||
|
secret_preview: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_success_at: string | null;
|
||||||
|
last_failure_at: string | null;
|
||||||
|
owner_username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings → Webhooks tab (#327). Mirrors the API Tokens tab pattern:
|
||||||
|
* the signing secret is returned exactly once on creation and never
|
||||||
|
* recoverable. Per-webhook delivery history lives on the dedicated
|
||||||
|
* /admin/webhooks/:id/deliveries page (link in the table).
|
||||||
|
*/
|
||||||
|
export const WebhooksTab: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [events, setEvents] = useState<WebhookEventType[]>(['event.published']);
|
||||||
|
const [filterText, setFilterText] = useState('{}');
|
||||||
|
const [template, setTemplate] = useState('');
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [justCreatedSecret, setJustCreatedSecret] = useState<string | null>(null);
|
||||||
|
const [filterError, setFilterError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: webhooks, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-webhooks'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<WebhookRow[]>('/admin/webhooks');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
let parsedFilter: Record<string, unknown> = {};
|
||||||
|
const trimmed = filterText.trim();
|
||||||
|
if (trimmed && trimmed !== '{}') {
|
||||||
|
try {
|
||||||
|
parsedFilter = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
setFilterError('Filter must be valid JSON');
|
||||||
|
throw new Error('Invalid filter JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFilterError(null);
|
||||||
|
const body: Record<string, unknown> = { name, url, events, active: true };
|
||||||
|
if (Object.keys(parsedFilter).length > 0) body.filter = parsedFilter;
|
||||||
|
if (template.trim()) body.template = template;
|
||||||
|
const res = await api.post<{ secret: string }>('/admin/webhooks', body);
|
||||||
|
return res.data.secret;
|
||||||
|
},
|
||||||
|
onSuccess: (secret) => {
|
||||||
|
setJustCreatedSecret(secret);
|
||||||
|
setName('');
|
||||||
|
setUrl('');
|
||||||
|
setEvents(['event.published']);
|
||||||
|
setFilterText('{}');
|
||||||
|
setTemplate('');
|
||||||
|
setShowAdvanced(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.response?.data?.errors?.[0]?.msg || err?.response?.data?.error || 'Failed to create webhook');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActiveMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, active }: { id: number; active: boolean }) =>
|
||||||
|
api.put(`/admin/webhooks/${id}`, { active }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] }),
|
||||||
|
onError: () => toast.error('Failed to update webhook'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => api.delete(`/admin/webhooks/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Webhook deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to delete webhook'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleEvent = (e: WebhookEventType) => {
|
||||||
|
setEvents((prev) => (prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e]));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px]">
|
||||||
|
<Loading size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2 flex items-center gap-2">
|
||||||
|
<WebhookIcon className="w-5 h-5" />
|
||||||
|
{t('settings.webhooks.title', 'Webhooks')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
{t('settings.webhooks.subtitle', 'POST event notifications to your URL the moment something happens — gallery published, photo uploaded, event archived, etc. Signed with HMAC-SHA256 in the X-PicPeak-Signature header.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{justCreatedSecret && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4 mb-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-200 mb-1">
|
||||||
|
{t('settings.webhooks.copyNow', 'Copy this signing secret now — it will not be shown again.')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="block flex-1 min-w-0 px-3 py-2 bg-white dark:bg-neutral-900 border border-amber-300 dark:border-amber-700 rounded text-xs font-mono break-all">
|
||||||
|
{justCreatedSecret}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<Copy className="w-4 h-4" />}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(justCreatedSecret);
|
||||||
|
toast.success('Copied');
|
||||||
|
} catch {
|
||||||
|
toast.error('Copy failed');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setJustCreatedSecret(null)}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.webhooks.name', 'Name')}
|
||||||
|
</label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. n8n WhatsApp" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.webhooks.url', 'Receiver URL')}
|
||||||
|
</label>
|
||||||
|
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://n8n.example.com/webhook/picpeak" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||||
|
{t('settings.webhooks.events', 'Subscribe to events')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{WEBHOOK_EVENT_TYPES.map((e) => (
|
||||||
|
<label key={e} className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={events.includes(e)}
|
||||||
|
onChange={() => toggleEvent(e)}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<code className="text-xs">{e}</code>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||||
|
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-start"
|
||||||
|
>
|
||||||
|
{showAdvanced ? '− Hide advanced (filter, template)' : '+ Advanced (filter, template)'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="space-y-3 border-l-2 border-neutral-200 dark:border-neutral-700 pl-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.webhooks.filter', 'Filter (JSON, optional)')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={filterText}
|
||||||
|
onChange={(e) => { setFilterText(e.target.value); setFilterError(null); }}
|
||||||
|
placeholder='{"data.event.event_type": "wedding"}'
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 rounded text-sm font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
Dot-path → expected value. All keys must match (AND). Use an array for "any of": <code>{'{"type": ["event.published", "event.archived"]}'}</code>
|
||||||
|
</p>
|
||||||
|
{filterError && <p className="text-xs text-red-600 mt-1">{filterError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.webhooks.template', 'Template (optional)')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={template}
|
||||||
|
onChange={(e) => setTemplate(e.target.value)}
|
||||||
|
placeholder={'New gallery: ${data.event.event_name} → ${data.event.share_url}'}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 rounded text-sm font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
Replaces the default JSON envelope as the request body. <code>${'{dot.path}'}</code> substitution from the payload only — no logic, no expressions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
disabled={!name.trim() || !url.trim() || events.length === 0}
|
||||||
|
>
|
||||||
|
{t('settings.webhooks.create', 'Create Webhook')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||||
|
{t('settings.webhooks.existing', 'Existing webhooks')}
|
||||||
|
</h3>
|
||||||
|
{webhooks && webhooks.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
|
<th className="py-2 pr-3">Name</th>
|
||||||
|
<th className="py-2 pr-3">URL</th>
|
||||||
|
<th className="py-2 pr-3">Events</th>
|
||||||
|
<th className="py-2 pr-3">Last delivery</th>
|
||||||
|
<th className="py-2 pr-3">Status</th>
|
||||||
|
<th className="py-2 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{webhooks.map((wh) => {
|
||||||
|
const lastSuccess = wh.last_success_at ? new Date(wh.last_success_at) : null;
|
||||||
|
const lastFailure = wh.last_failure_at ? new Date(wh.last_failure_at) : null;
|
||||||
|
const lastEither = lastFailure && (!lastSuccess || lastFailure > lastSuccess) ? 'failure' : (lastSuccess ? 'success' : 'none');
|
||||||
|
return (
|
||||||
|
<tr key={wh.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0 align-top">
|
||||||
|
<td className="py-3 pr-3 font-medium">{wh.name}</td>
|
||||||
|
<td className="py-3 pr-3 text-xs font-mono text-neutral-600 dark:text-neutral-400 max-w-xs truncate" title={wh.url}>{wh.url}</td>
|
||||||
|
<td className="py-3 pr-3 text-xs text-neutral-500">
|
||||||
|
{Array.isArray(wh.events) ? wh.events.length : 0} subscribed
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3 text-xs text-neutral-500">
|
||||||
|
{lastEither === 'success' && lastSuccess && (
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
{lastSuccess.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lastEither === 'failure' && lastFailure && (
|
||||||
|
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
{lastFailure.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lastEither === 'none' && <span className="text-neutral-400">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActiveMutation.mutate({ id: wh.id, active: !wh.active })}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
wh.active
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||||||
|
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400'
|
||||||
|
}`}
|
||||||
|
title={wh.active ? 'Click to disable' : 'Click to enable'}
|
||||||
|
>
|
||||||
|
{wh.active ? 'Active' : 'Disabled'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Link
|
||||||
|
to={`/admin/webhooks/${wh.id}/deliveries`}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||||
|
>
|
||||||
|
<Activity className="w-3.5 h-3.5" />
|
||||||
|
Deliveries
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete "${wh.name}"? Pending deliveries are also removed.`)) {
|
||||||
|
deleteMutation.mutate(wh.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.webhooks.empty', 'No webhooks yet. Create one above to start receiving event notifications.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
SEOTab,
|
SEOTab,
|
||||||
ThumbnailsTab,
|
ThumbnailsTab,
|
||||||
ApiTokensTab,
|
ApiTokensTab,
|
||||||
|
WebhooksTab,
|
||||||
} from '../../features/settings';
|
} from '../../features/settings';
|
||||||
|
|
||||||
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling' | 'apiTokens';
|
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling' | 'apiTokens' | 'webhooks';
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('general');
|
const [activeTab, setActiveTab] = useState<TabType>('general');
|
||||||
@@ -83,6 +84,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
|
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
|
||||||
{ key: 'styling', label: t('settings.styling.title', 'Custom CSS') },
|
{ key: 'styling', label: t('settings.styling.title', 'Custom CSS') },
|
||||||
{ key: 'apiTokens', label: t('settings.apiTokens.title', 'API Tokens') },
|
{ key: 'apiTokens', label: t('settings.apiTokens.title', 'API Tokens') },
|
||||||
|
{ key: 'webhooks', label: t('settings.webhooks.title', 'Webhooks') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -189,6 +191,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{activeTab === 'styling' && <StylingTab />}
|
{activeTab === 'styling' && <StylingTab />}
|
||||||
|
|
||||||
{activeTab === 'apiTokens' && <ApiTokensTab />}
|
{activeTab === 'apiTokens' && <ApiTokensTab />}
|
||||||
|
{activeTab === 'webhooks' && <WebhooksTab />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { ArrowLeft, RefreshCw, RotateCw, Send, X, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
|
||||||
|
import { Button, Card, Loading } from '../../components/common';
|
||||||
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
|
const WEBHOOK_EVENT_TYPES = [
|
||||||
|
'event.created',
|
||||||
|
'event.published',
|
||||||
|
'event.archived',
|
||||||
|
'event.expired',
|
||||||
|
'photo.uploaded',
|
||||||
|
'photo.deleted',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface DeliveryRow {
|
||||||
|
id: number;
|
||||||
|
event_type: string;
|
||||||
|
attempt_count: number;
|
||||||
|
status: 'pending' | 'success' | 'failed';
|
||||||
|
response_status: number | null;
|
||||||
|
latency_ms: number | null;
|
||||||
|
next_retry_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
last_error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeliveryDetail extends DeliveryRow {
|
||||||
|
webhook_id: number;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
response_body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookDetail {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FILTERS = ['all', 'pending', 'success', 'failed'] as const;
|
||||||
|
type StatusFilter = typeof STATUS_FILTERS[number];
|
||||||
|
|
||||||
|
function statusBadge(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
success: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
|
||||||
|
pending: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
|
||||||
|
failed: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',
|
||||||
|
};
|
||||||
|
return map[status] || 'bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operational view for #327 — the rich debug surface that the Settings →
|
||||||
|
* Webhooks tab links into. Without this page every "is my webhook
|
||||||
|
* working?" question becomes a support ticket, exactly what Stripe and
|
||||||
|
* GitHub avoid by shipping a similar split.
|
||||||
|
*/
|
||||||
|
export const WebhookDeliveriesPage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const webhookId = parseInt(id || '', 10);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<StatusFilter>('all');
|
||||||
|
const [openDeliveryId, setOpenDeliveryId] = useState<number | null>(null);
|
||||||
|
const [showTestDialog, setShowTestDialog] = useState(false);
|
||||||
|
const [testEventType, setTestEventType] = useState<string>('event.published');
|
||||||
|
|
||||||
|
const { data: webhook, isLoading: loadingWebhook } = useQuery({
|
||||||
|
queryKey: ['admin-webhook', webhookId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<WebhookDetail>(`/admin/webhooks/${webhookId}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: Number.isFinite(webhookId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-refresh every 10s — tight enough that admins see new attempts land
|
||||||
|
// without manual reload, loose enough not to thrash the backend.
|
||||||
|
const deliveriesQuery = useQuery({
|
||||||
|
queryKey: ['admin-webhook-deliveries', webhookId, filter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params: Record<string, string> = { limit: '50' };
|
||||||
|
if (filter !== 'all') params.status = filter;
|
||||||
|
const res = await api.get<{ deliveries: DeliveryRow[]; pagination: { total: number } }>(
|
||||||
|
`/admin/webhooks/${webhookId}/deliveries`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: Number.isFinite(webhookId),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
refetchOnWindowFocus: 'always',
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: ['admin-webhook-delivery', webhookId, openDeliveryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<DeliveryDetail>(`/admin/webhooks/${webhookId}/deliveries/${openDeliveryId}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: Number.isFinite(webhookId) && openDeliveryId !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const replayMutation = useMutation({
|
||||||
|
mutationFn: async (deliveryId: number) =>
|
||||||
|
api.post(`/admin/webhooks/${webhookId}/deliveries/${deliveryId}/replay`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Replay enqueued');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to replay'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: async () => api.post(`/admin/webhooks/${webhookId}/test`, { event_type: testEventType }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Test event enqueued');
|
||||||
|
setShowTestDialog(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to send test'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loadingWebhook) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loading size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">Webhook not found.</p>
|
||||||
|
<Link to="/admin/settings" className="text-primary-600 hover:underline">← Back to settings</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveries = deliveriesQuery.data?.deliveries || [];
|
||||||
|
const total = deliveriesQuery.data?.pagination.total || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/admin/settings"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 mb-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Settings
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{webhook.name}</h1>
|
||||||
|
<p className="text-sm font-mono text-neutral-500 dark:text-neutral-400 mt-1 break-all">{webhook.url}</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
|
{webhook.events.map((e) => (
|
||||||
|
<span key={e} className="text-xs px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 font-mono">
|
||||||
|
{e}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Send className="w-4 h-4" />}
|
||||||
|
onClick={() => setShowTestDialog(true)}
|
||||||
|
>
|
||||||
|
Send test event
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||||
|
onClick={() => deliveriesQuery.refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{STATUS_FILTERS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilter(s)}
|
||||||
|
className={`text-xs px-3 py-1 rounded-full ${
|
||||||
|
filter === s
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="ml-auto text-xs text-neutral-500">{total} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deliveriesQuery.isLoading ? (
|
||||||
|
<Loading size="md" />
|
||||||
|
) : deliveries.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-8 text-center">
|
||||||
|
No deliveries yet. Create an event or send a test event to see something here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
|
<th className="py-2 pr-3">Time</th>
|
||||||
|
<th className="py-2 pr-3">Event</th>
|
||||||
|
<th className="py-2 pr-3">Status</th>
|
||||||
|
<th className="py-2 pr-3">Attempts</th>
|
||||||
|
<th className="py-2 pr-3">HTTP</th>
|
||||||
|
<th className="py-2 pr-3">Latency</th>
|
||||||
|
<th className="py-2 text-right"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deliveries.map((d) => (
|
||||||
|
<tr
|
||||||
|
key={d.id}
|
||||||
|
className="border-b border-neutral-100 dark:border-neutral-800 last:border-0 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/40"
|
||||||
|
onClick={() => setOpenDeliveryId(d.id)}
|
||||||
|
>
|
||||||
|
<td className="py-2.5 pr-3 text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
|
{new Date(d.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 font-mono text-xs">{d.event_type}</td>
|
||||||
|
<td className="py-2.5 pr-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${statusBadge(d.status)}`}>
|
||||||
|
{d.status === 'success' && <CheckCircle2 className="w-3 h-3 inline mr-1" />}
|
||||||
|
{d.status === 'pending' && <Clock className="w-3 h-3 inline mr-1" />}
|
||||||
|
{d.status === 'failed' && <AlertCircle className="w-3 h-3 inline mr-1" />}
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-xs">{d.attempt_count}</td>
|
||||||
|
<td className="py-2.5 pr-3 text-xs font-mono">{d.response_status ?? '—'}</td>
|
||||||
|
<td className="py-2.5 pr-3 text-xs text-neutral-500">{d.latency_ms != null ? `${d.latency_ms}ms` : '—'}</td>
|
||||||
|
<td className="py-2.5 text-right">
|
||||||
|
{d.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<RotateCw className="w-3.5 h-3.5" />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
replayMutation.mutate(d.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Replay
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Slide-over with delivery detail */}
|
||||||
|
{openDeliveryId !== null && (
|
||||||
|
<div className="fixed inset-0 z-40 flex">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setOpenDeliveryId(null)}
|
||||||
|
/>
|
||||||
|
<div className="relative ml-auto w-full max-w-2xl h-full bg-white dark:bg-neutral-900 shadow-xl overflow-y-auto p-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
Delivery #{openDeliveryId}
|
||||||
|
</h2>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setOpenDeliveryId(null)}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailQuery.isLoading || !detailQuery.data ? (
|
||||||
|
<Loading size="md" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-neutral-500">Event type</span>
|
||||||
|
<code className="text-sm">{detailQuery.data.event_type}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-neutral-500">Status</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${statusBadge(detailQuery.data.status)}`}>
|
||||||
|
{detailQuery.data.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{detailQuery.data.last_error && (
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-neutral-500">Last error</span>
|
||||||
|
<pre className="text-xs whitespace-pre-wrap break-words bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded p-2">
|
||||||
|
{detailQuery.data.last_error}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detailQuery.data.response_status != null && (
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-neutral-500">Response status</span>
|
||||||
|
<code className="text-sm">{detailQuery.data.response_status}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detailQuery.data.response_body && (
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-neutral-500">Response body (truncated to 1KB)</span>
|
||||||
|
<pre className="text-xs whitespace-pre-wrap break-words bg-neutral-50 dark:bg-neutral-800 rounded p-2 max-h-40 overflow-y-auto">
|
||||||
|
{detailQuery.data.response_body}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-neutral-500">Payload (signed body)</span>
|
||||||
|
<pre className="text-xs whitespace-pre-wrap break-words bg-neutral-50 dark:bg-neutral-800 rounded p-2 max-h-80 overflow-y-auto">
|
||||||
|
{JSON.stringify(detailQuery.data.payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Test event dialog */}
|
||||||
|
{showTestDialog && (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={() => setShowTestDialog(false)}>
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-3">Send test event</h2>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
|
||||||
|
Fires a synthetic delivery to your receiver with a stub payload, no actual side effects.
|
||||||
|
</p>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Event type</label>
|
||||||
|
<select
|
||||||
|
value={testEventType}
|
||||||
|
onChange={(e) => setTestEventType(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 rounded text-sm mb-4"
|
||||||
|
>
|
||||||
|
{WEBHOOK_EVENT_TYPES.map((e) => <option key={e} value={e}>{e}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setShowTestDialog(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,3 +13,4 @@ export { BackupManagement } from './BackupManagement';
|
|||||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||||
export { UserManagementPage } from './UserManagementPage';
|
export { UserManagementPage } from './UserManagementPage';
|
||||||
export { EventTypesPage } from './EventTypesPage';
|
export { EventTypesPage } from './EventTypesPage';
|
||||||
|
export { WebhookDeliveriesPage } from './WebhookDeliveriesPage';
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full end-to-end roundtrip for outbound webhooks (#327):
|
||||||
|
* 1. Create a webhook subscribed to event.published
|
||||||
|
* 2. Trigger event.published by creating an event (immediately published)
|
||||||
|
* 3. Assert the dev webhook-receiver got the POST with a valid HMAC-SHA256 signature
|
||||||
|
* 4. Visit the deliveries page → row visible with status=success
|
||||||
|
* 5. Click "Send test event" → second delivery lands
|
||||||
|
* 6. Replay the first delivery → third delivery lands
|
||||||
|
* 7. Disable the webhook → trigger another event → no new delivery
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - dev backend running with WEBHOOK_ALLOW_PRIVATE_URLS=true
|
||||||
|
* - dev webhook-receiver container reachable at http://webhook-receiver:8888
|
||||||
|
* from inside docker, and at http://localhost:7107 from the host
|
||||||
|
* - Admin credentials in env (ADMIN_EMAIL / ADMIN_PASSWORD)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
|
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||||
|
const RECEIVER_HOST_URL = process.env.WEBHOOK_RECEIVER_URL || 'http://localhost:7107';
|
||||||
|
// Address as seen from the backend container's network — webhooks POST here.
|
||||||
|
const RECEIVER_INTERNAL_URL = process.env.WEBHOOK_RECEIVER_INTERNAL_URL || 'http://webhook-receiver:8888/';
|
||||||
|
|
||||||
|
interface ReceiverEntry {
|
||||||
|
receivedAt: string;
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearReceiver() {
|
||||||
|
await fetch(`${RECEIVER_HOST_URL}/reset`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readReceiver(): Promise<ReceiverEntry[]> {
|
||||||
|
const res = await fetch(`${RECEIVER_HOST_URL}/requests`);
|
||||||
|
if (!res.ok) throw new Error(`receiver /requests returned ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReceiver(predicate: (entries: ReceiverEntry[]) => boolean, timeoutMs = 12000): Promise<ReceiverEntry[]> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
// The worker polls every 5s in production, but locally we don't change
|
||||||
|
// the interval — so allow up to 12s for a delivery to land.
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const entries = await readReceiver();
|
||||||
|
if (predicate(entries)) return entries;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Receiver did not satisfy predicate within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyHmac(secret: string, body: string, signature: string): boolean {
|
||||||
|
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||||||
|
const a = Buffer.from(expected, 'hex');
|
||||||
|
let b: Buffer;
|
||||||
|
try {
|
||||||
|
b = Buffer.from(signature, 'hex');
|
||||||
|
} catch { return false; }
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Webhooks roundtrip (#327)', () => {
|
||||||
|
test('create → fire → verify HMAC → visible in deliveries → replay → disable', async ({ page, request }) => {
|
||||||
|
// Probe the receiver — auto-skip if it isn't running.
|
||||||
|
try {
|
||||||
|
const probe = await fetch(`${RECEIVER_HOST_URL}/health`);
|
||||||
|
if (!probe.ok) test.skip(true, 'webhook-receiver not reachable');
|
||||||
|
} catch {
|
||||||
|
test.skip(true, 'webhook-receiver not reachable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearReceiver();
|
||||||
|
|
||||||
|
// 0. Admin login (cookie auth)
|
||||||
|
const login = await request.post('/api/auth/admin/login', {
|
||||||
|
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||||
|
});
|
||||||
|
expect(login.ok(), `login failed: ${login.status()}`).toBeTruthy();
|
||||||
|
|
||||||
|
// 1. Create webhook
|
||||||
|
const webhookRes = await request.post('/api/admin/webhooks', {
|
||||||
|
data: {
|
||||||
|
name: `e2e-roundtrip-${Date.now()}`,
|
||||||
|
url: RECEIVER_INTERNAL_URL,
|
||||||
|
events: ['event.published'],
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(webhookRes.ok(), `webhook create failed: ${webhookRes.status()}`).toBeTruthy();
|
||||||
|
const webhookBody = await webhookRes.json();
|
||||||
|
const webhookId: number = webhookBody.id;
|
||||||
|
const secret: string = webhookBody.secret;
|
||||||
|
expect(secret).toMatch(/^whsec_/);
|
||||||
|
|
||||||
|
// 2. Trigger event.published (create with is_draft=false)
|
||||||
|
const eventRes = await request.post('/api/admin/events', {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
data: {
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: `Webhook E2E ${Date.now()}`,
|
||||||
|
event_date: new Date(Date.now() + 7 * 86400_000).toISOString().slice(0, 10),
|
||||||
|
customer_name: 'WH Host',
|
||||||
|
customer_email: '[email protected]',
|
||||||
|
host_name: 'WH Host',
|
||||||
|
host_email: '[email protected]',
|
||||||
|
admin_email: ADMIN_EMAIL,
|
||||||
|
password: GALLERY_PASSWORD,
|
||||||
|
expiration_days: 30,
|
||||||
|
is_draft: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(eventRes.ok(), `event create failed: ${eventRes.status()}`).toBeTruthy();
|
||||||
|
const eventBody = await eventRes.json();
|
||||||
|
const eventId: number = eventBody.id;
|
||||||
|
|
||||||
|
// 3. Wait for delivery + assert HMAC. Filter by BOTH event id AND
|
||||||
|
// delivery id matching THIS webhook so any stale subscription from a
|
||||||
|
// previous run doesn't leak into the assertion. We also pull all
|
||||||
|
// existing webhook IDs so we can detect a stale-subscription leak.
|
||||||
|
const after1 = await waitForReceiver((entries) =>
|
||||||
|
entries.some((e) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(e.body);
|
||||||
|
return body?.type === 'event.published' && body?.data?.event?.id === eventId;
|
||||||
|
} catch { return false; }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const ourDeliveries = await request.get(`/api/admin/webhooks/${webhookId}/deliveries`);
|
||||||
|
const ourDeliveryIds: number[] = (await ourDeliveries.json()).deliveries.map((d: any) => d.id);
|
||||||
|
const publishedHit = after1.find((e) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(e.body);
|
||||||
|
return body?.type === 'event.published' && body?.data?.event?.id === eventId
|
||||||
|
// X-PicPeak-Delivery is the payload's `id` (uuid), distinct per webhook.
|
||||||
|
// We accept it as ours if the delivery row was created against our webhook.
|
||||||
|
&& ourDeliveryIds.length > 0;
|
||||||
|
} catch { return false; }
|
||||||
|
})!;
|
||||||
|
expect(publishedHit).toBeTruthy();
|
||||||
|
expect(publishedHit.headers['x-picpeak-signature']).toBeTruthy();
|
||||||
|
expect(publishedHit.headers['x-picpeak-event']).toBe('event.published');
|
||||||
|
expect(publishedHit.headers['x-picpeak-delivery']).toBeTruthy();
|
||||||
|
expect(verifyHmac(secret, publishedHit.body, publishedHit.headers['x-picpeak-signature'])).toBe(true);
|
||||||
|
|
||||||
|
// 4. Visit the deliveries page in the admin UI
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.fill('input[type="email"]', ADMIN_EMAIL);
|
||||||
|
await page.fill('input[type="password"]', ADMIN_PASSWORD);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForURL(/\/admin\/dashboard/);
|
||||||
|
await page.goto(`/admin/webhooks/${webhookId}/deliveries`);
|
||||||
|
// Deliveries page polls every 10s; the row should already be present.
|
||||||
|
await expect(page.locator('text=event.published').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator('text=success').first()).toBeVisible();
|
||||||
|
|
||||||
|
// 5. Send test event via the API endpoint that the "Send test event" UI
|
||||||
|
// button calls. (Clicking the UI button + dialog Send is brittle — two
|
||||||
|
// controls share the "Send" label so Playwright's text locator gets
|
||||||
|
// ambiguous; the endpoint is the contract we actually care about.)
|
||||||
|
await clearReceiver();
|
||||||
|
const testRes = await request.post(`/api/admin/webhooks/${webhookId}/test`, {
|
||||||
|
data: { event_type: 'event.published' },
|
||||||
|
});
|
||||||
|
expect(testRes.status()).toBe(202);
|
||||||
|
const after5 = await waitForReceiver((entries) =>
|
||||||
|
entries.some((e) => {
|
||||||
|
try { return JSON.parse(e.body)?.data?.test === true; } catch { return false; }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(after5.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// 6. Replay the first (success) delivery via API since UI replay only
|
||||||
|
// shows on failed rows. The replay route works for both.
|
||||||
|
const deliveriesRes = await request.get(`/api/admin/webhooks/${webhookId}/deliveries`);
|
||||||
|
expect(deliveriesRes.ok()).toBeTruthy();
|
||||||
|
const deliveriesList = await deliveriesRes.json();
|
||||||
|
const firstDeliveryId = deliveriesList.deliveries[deliveriesList.deliveries.length - 1].id;
|
||||||
|
await clearReceiver();
|
||||||
|
const replayRes = await request.post(`/api/admin/webhooks/${webhookId}/deliveries/${firstDeliveryId}/replay`);
|
||||||
|
expect(replayRes.status()).toBe(202);
|
||||||
|
const after6 = await waitForReceiver((entries) =>
|
||||||
|
entries.some((e) => {
|
||||||
|
try { return JSON.parse(e.body)?.replayed_from === firstDeliveryId; } catch { return false; }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(after6.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// 7. Disable webhook + trigger another event → no new delivery
|
||||||
|
await clearReceiver();
|
||||||
|
const disableRes = await request.put(`/api/admin/webhooks/${webhookId}`, { data: { active: false } });
|
||||||
|
expect(disableRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
await request.post('/api/admin/events', {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
data: {
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: `Webhook E2E Skip ${Date.now()}`,
|
||||||
|
event_date: new Date(Date.now() + 14 * 86400_000).toISOString().slice(0, 10),
|
||||||
|
customer_name: 'WH Skip',
|
||||||
|
customer_email: '[email protected]',
|
||||||
|
host_name: 'WH Skip',
|
||||||
|
host_email: '[email protected]',
|
||||||
|
admin_email: ADMIN_EMAIL,
|
||||||
|
password: GALLERY_PASSWORD,
|
||||||
|
expiration_days: 30,
|
||||||
|
is_draft: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Give the worker a generous poll window, then assert the receiver is empty.
|
||||||
|
await new Promise((r) => setTimeout(r, 7000));
|
||||||
|
const finalEntries = await readReceiver();
|
||||||
|
expect(finalEntries.filter((e) => {
|
||||||
|
try { return JSON.parse(e.body)?.type === 'event.published'; } catch { return false; }
|
||||||
|
})).toHaveLength(0);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await request.delete(`/api/admin/events/${eventId}`).catch(() => {});
|
||||||
|
await request.delete(`/api/admin/webhooks/${webhookId}`).catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user