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.
124 lines
4.3 KiB
JavaScript
124 lines
4.3 KiB
JavaScript
const cron = require('node-cron');
|
|
const { db } = require('../database/db');
|
|
const { archiveEvent } = require('./archiveService');
|
|
const { queueEmail } = require('./emailProcessor');
|
|
const logger = require('../utils/logger');
|
|
const { formatDate } = require('../utils/dateFormatter');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
|
|
function startExpirationChecker() {
|
|
// Check every hour for expired events and warnings
|
|
cron.schedule('0 * * * *', async () => {
|
|
await checkExpirations();
|
|
});
|
|
|
|
logger.info('Expiration checker started');
|
|
}
|
|
|
|
async function checkExpirations() {
|
|
try {
|
|
const now = new Date();
|
|
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
|
|
|
|
// Check for events needing warning emails
|
|
// Skip events with null expires_at (they never expire)
|
|
const eventsNeedingWarning = await db('events')
|
|
.where('is_active', formatBoolean(true))
|
|
.where('is_archived', formatBoolean(false))
|
|
.whereNotNull('expires_at')
|
|
.where('expires_at', '<=', warningDate)
|
|
.where('expires_at', '>', now);
|
|
|
|
for (const event of eventsNeedingWarning) {
|
|
// Check if warning email already sent
|
|
const existingWarning = await db('email_queue')
|
|
.where('event_id', event.id)
|
|
.where('email_type', 'expiration_warning')
|
|
.first();
|
|
|
|
if (!existingWarning) {
|
|
await queueExpirationWarning(event);
|
|
}
|
|
}
|
|
|
|
// Check for expired events
|
|
// Skip events with null expires_at (they never expire)
|
|
const expiredEvents = await db('events')
|
|
.where('is_active', formatBoolean(true))
|
|
.where('is_archived', formatBoolean(false))
|
|
.whereNotNull('expires_at')
|
|
.where('expires_at', '<=', now);
|
|
|
|
for (const event of expiredEvents) {
|
|
await handleExpiredEvent(event);
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error('Error checking expirations:', error);
|
|
}
|
|
}
|
|
|
|
async function queueExpirationWarning(event) {
|
|
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
|
|
|
|
// Determine language based on email domain
|
|
const recipientEmail = event.customer_email || event.host_email;
|
|
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
|
const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en';
|
|
|
|
// Queue email to customer
|
|
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
|
|
customer_name: recipientName,
|
|
customer_email: recipientEmail,
|
|
host_name: recipientName,
|
|
event_name: event.event_name,
|
|
days_remaining: daysRemaining.toString(),
|
|
expiration_date: await formatDate(event.expires_at, emailLang),
|
|
gallery_link: event.share_link
|
|
});
|
|
|
|
logger.info(`Queued expiration warning for event ${event.slug}`);
|
|
}
|
|
|
|
async function handleExpiredEvent(event) {
|
|
try {
|
|
// Mark as inactive
|
|
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
|
|
const recipientEmail = event.customer_email || event.host_email;
|
|
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
|
|
|
await queueEmail(event.id, recipientEmail, 'gallery_expired', {
|
|
event_name: event.event_name,
|
|
admin_email: event.admin_email,
|
|
customer_name: recipientName,
|
|
customer_email: recipientEmail
|
|
});
|
|
|
|
// Also notify admin
|
|
await queueEmail(event.id, event.admin_email, 'gallery_expired', {
|
|
event_name: event.event_name,
|
|
admin_email: event.admin_email
|
|
});
|
|
|
|
// Start archiving process
|
|
await archiveEvent(event);
|
|
|
|
logger.info(`Handled expiration for event ${event.slug}`);
|
|
} catch (error) {
|
|
logger.error(`Error handling expired event ${event.slug}:`, error);
|
|
}
|
|
}
|
|
|
|
module.exports = { startExpirationChecker };
|