406c638451
safeValidationErrors moves to utils/routeHelpers and replaces every
res.status(400).json({ errors: errors.array() }) in the routes, so no 400
body carries the submitted value any more (setup, customer auth and
customer change-password were still echoing rejected passwords).
Admin login, gallery verify, customer login/register/reset, customer
change-password and setup now cap username/slug at 255 and passwords at
MAX_PASSWORD_LENGTH at the validator, so an oversized value never reaches
the lockout lookup, bcrypt or the failed-attempt log.
Admin and customer login run one bcrypt compare on every path; the unknown
account branch used to return in microseconds against ~100ms for a wrong
password, which enumerated usernames despite the generic message.
(cherry picked from commit 40a8a9882a)
391 lines
16 KiB
JavaScript
391 lines
16 KiB
JavaScript
/**
|
|
* 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 { safeValidationErrors } = require('../utils/routeHelpers');
|
|
const { db, logActivity } = require('../database/db');
|
|
const { adminAuth } = require('../middleware/auth');
|
|
const { requirePermission } = require('../middleware/permissions');
|
|
const { validateExternalUrlAsync } = 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(async (url) => {
|
|
if (ALLOW_PRIVATE_URLS) return true;
|
|
const check = await validateExternalUrlAsync(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: safeValidationErrors(errors) });
|
|
|
|
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(async (url) => {
|
|
if (ALLOW_PRIVATE_URLS) return true;
|
|
const check = await validateExternalUrlAsync(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: safeValidationErrors(errors) });
|
|
|
|
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: safeValidationErrors(errors) });
|
|
|
|
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: safeValidationErrors(errors) });
|
|
|
|
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;
|