-
-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathwebhooks.js
More file actions
106 lines (89 loc) · 3.42 KB
/
Copy pathwebhooks.js
File metadata and controls
106 lines (89 loc) · 3.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// GET /api/developer/webhooks — list user's registered webhooks
// POST /api/developer/webhooks — create a new webhook
import { cors, error, json, method, readJson, wrap } from '../_lib/http.js';
import { getSessionUser } from '../_lib/auth.js';
import { requireCsrf } from '../_lib/csrf.js';
import { sql } from '../_lib/db.js';
import { randomToken } from '../_lib/crypto.js';
import { EVENT_TYPES } from '../_lib/webhook-dispatch.js';
import { assertPublicHttpsUrl } from '../_lib/ssrf.js';
const MAX_WEBHOOKS_PER_USER = 10;
const URL_MAX_LENGTH = 2048;
export default wrap(async function handler(req, res) {
if (cors(req, res, { methods: 'GET,POST,OPTIONS', credentials: true })) return;
const user = await getSessionUser(req, res);
if (!user) return error(res, 401, 'unauthorized', 'Sign in required');
if (req.method === 'GET') {
const webhooks = await sql`
select id, url, events, active, description, created_at, updated_at
from developer_webhooks
where user_id = ${user.id}
order by created_at desc
`;
const withStats = await Promise.all(
webhooks.map(async (wh) => {
const [stats] = await sql`
select
count(*)::int as total,
count(*) filter (where status_code between 200 and 299)::int as succeeded,
count(*) filter (where status_code is null or status_code >= 400)::int as failed,
max(created_at) as last_delivery_at
from webhook_deliveries
where webhook_id = ${wh.id} and created_at > now() - interval '7 days'
`;
return { ...wh, stats_7d: stats };
}),
);
return json(res, 200, { webhooks: withStats, event_types: EVENT_TYPES });
}
if (!method(req, res, ['POST'])) return;
if (!(await requireCsrf(req, res, user.id))) return;
let body;
try {
body = await readJson(req, 5000);
} catch (e) {
return error(res, e.status || 400, 'bad_request', e.message);
}
const url = typeof body.url === 'string' ? body.url.trim() : '';
if (!url) return error(res, 400, 'bad_request', 'url is required');
if (url.length > URL_MAX_LENGTH)
return error(res, 400, 'bad_request', `url exceeds ${URL_MAX_LENGTH} characters`);
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') {
return error(res, 400, 'bad_request', 'Webhook URL must use HTTPS');
}
} catch {
return error(res, 400, 'bad_request', 'Invalid URL');
}
// Reject URLs that resolve to a private/loopback/link-local/metadata address
// at registration time (SSRF). Delivery re-validates and pins per attempt.
try {
await assertPublicHttpsUrl(url);
} catch {
return error(res, 400, 'bad_request', 'Webhook URL must resolve to a public address');
}
const events = Array.isArray(body.events)
? body.events.filter((e) => EVENT_TYPES.includes(e))
: [];
const description =
typeof body.description === 'string' ? body.description.trim().slice(0, 200) : null;
const [{ count: existing }] = await sql`
select count(*)::int as count from developer_webhooks where user_id = ${user.id}
`;
if (existing >= MAX_WEBHOOKS_PER_USER) {
return error(
res,
409,
'limit_reached',
`Maximum ${MAX_WEBHOOKS_PER_USER} webhooks per account`,
);
}
const secret = `whsec_${randomToken(24)}`;
const [webhook] = await sql`
insert into developer_webhooks (user_id, url, secret, events, description)
values (${user.id}, ${url}, ${secret}, ${events}, ${description})
returning id, url, events, active, description, created_at
`;
return json(res, 201, { webhook: { ...webhook, secret } });
});