The event.published webhook reporter wired into n8n to send WhatsApp
gallery links was missing the data needed to actually message the
customer — only event_name + share_url were in the payload, no
customer_name / customer_email / customer_phone, and no bare share
token to construct alternate URLs.
Adds a single canonical event subject helper (webhookService.buildEventSubject)
so every event.* webhook returns the same shape:
{ id, slug, event_name, event_type, event_date,
share_url, share_token,
customer_name, customer_email, customer_phone }
Fields the caller does not have in scope come back as null — keys are
always present so receivers do not have to distinguish "field missing"
from "field null". Pure addition: existing receivers continue to work,
existing templates ${data.event.event_name} keep working, and new
templates can now reference ${data.event.customer_phone} etc.
Wired into all five firing sites:
- routes/events.js — public event create (created + published)
- routes/adminEvents.js — admin create + draft→publish
- routes/v1/events.js — public v1 API (created + published)
- services/expirationChecker.js — event.expired (extra: expires_at)
- services/archiveService.js — event.archived (extra: archive_path)
PII surface area widens (customer email/phone now flow to webhook
receivers), so:
- Settings → Webhooks UI gets an amber Callout above the create form
warning admins to only point webhooks at receivers they trust.
- Docs page updated with the new payload sample, the always-present
null contract, and a Callout warning.
Verified end-to-end against the local dev webhook receiver — delivered
payload contains all 10 fields. webhookDelivery integration suite
remains 8/8 green.
249 lines
8.2 KiB
JavaScript
249 lines
8.2 KiB
JavaScript
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; }
|
|
}
|
|
|
|
/**
|
|
* Canonical event sub-object for outbound webhooks (#341). Always returns
|
|
* the full key set so receivers don't have to handle "field missing vs
|
|
* field null" — pass whatever the caller has in scope, missing fields
|
|
* become null. Adding a new field here propagates to every event.* type
|
|
* payload at once.
|
|
*/
|
|
function buildEventSubject(input = {}) {
|
|
const e = input || {};
|
|
return {
|
|
id: e.id ?? null,
|
|
slug: e.slug ?? null,
|
|
event_name: e.event_name ?? null,
|
|
event_type: e.event_type ?? null,
|
|
event_date: e.event_date ?? null,
|
|
share_url: e.share_url ?? null,
|
|
share_token: e.share_token ?? null,
|
|
customer_name: e.customer_name ?? null,
|
|
customer_email: e.customer_email ?? null,
|
|
customer_phone: e.customer_phone ?? null,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
fire,
|
|
generateSecret,
|
|
signPayload,
|
|
verifySignature,
|
|
payloadMatchesFilter,
|
|
renderTemplate,
|
|
validateTemplate,
|
|
getByPath,
|
|
buildEventSubject,
|
|
EVENT_TYPES,
|
|
SECRET_PREFIX,
|
|
};
|