forked from SmartDropLabs/smartdrop-backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwebhooks.js
More file actions
273 lines (251 loc) · 8.35 KB
/
Copy pathwebhooks.js
File metadata and controls
273 lines (251 loc) · 8.35 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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
"use strict";
const express = require("express");
const config = require("../config");
const { validate } = require("../middleware/validate");
const webhookRepo = require("../repositories/webhookRepository");
const deliveryRepo = require("../repositories/deliveryRepository");
const dispatcher = require("../services/webhookDispatcher");
const signatureService = require("../services/webhookSignature");
const { probeReachability } = require("../services/webhook");
const { idempotencyMiddleware } = require("../services/idempotency");
const buildRateLimit = require("../middleware/rateLimit");
const { routeTimeout } = require("../middleware/timeout");
const AppError = require("../errors/AppError");
const { paginateResponse } = require("../utils/paginate");
const {
paginationQuerySchema,
routeIdParamsSchema,
webhookCreateBodySchema,
webhookDeliveriesQuerySchema,
webhookPatchBodySchema,
} = require("../validation/schemas");
const router = express.Router();
router.use(express.json({ limit: config.webhooks.jsonMaxBytes }));
const validateRouteIdParams = validate(routeIdParamsSchema, "params");
const validatePaginationQuery = validate(paginationQuerySchema, "query");
const manageLimit = buildRateLimit({
windowSeconds: config.webhooks.rateLimit.windowSeconds,
max: config.webhooks.rateLimit.max,
keyPrefix: "webhooks",
});
const testLimit = buildRateLimit({
windowSeconds: config.webhooks.testRateLimit.windowSeconds,
max: config.webhooks.testRateLimit.max,
keyPrefix: "webhooks_test",
});
function clientIpFromRequest(req) {
const forwardedFor = req.headers["x-forwarded-for"];
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
return forwardedFor
.split(",")[0]
.trim()
.replace(/^::ffff:/, "");
}
if (Array.isArray(forwardedFor) && forwardedFor[0]) {
return String(forwardedFor[0])
.trim()
.replace(/^::ffff:/, "");
}
return (req.ip || req.socket?.remoteAddress || "unknown").replace(
/^::ffff:/,
"",
);
}
router.use("/webhooks", manageLimit);
router.get("/webhooks/metrics", async (req, res) => {
return res.json(dispatcher.getMetrics());
});
/**
* Map a raw delivery error string to a coarse, non-leaky category for the
* externally-visible test-endpoint response. The raw low-level network error
* (ECONNREFUSED/ETIMEDOUT/ECONNRESET, etc.) is kept in server-side logs but
* must not be echoed back to the caller, since it is exactly what makes the
* test endpoint a useful internal-network reconnaissance oracle (see #96).
*/
function deliveryErrorCategory(rawError) {
if (!rawError) return null;
const msg = String(rawError);
if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|ENETUNREACH|EHOSTUNREACH|ECONNABORTED|socket hang up|network error/i.test(msg)) {
return 'unreachable';
}
if (/^HTTP \d+/.test(msg)) return 'error_response';
return 'delivery_failed';
}
function publicView(webhook) {
if (!webhook) return null;
return {
id: webhook.id,
url: webhook.url,
events: webhook.events,
filters: webhook.filters,
active: webhook.active,
description: webhook.description,
created_at: webhook.created_at,
updated_at: webhook.updated_at,
secret_preview: webhook.secret ? `${webhook.secret.slice(0, 10)}…` : null,
};
}
router.post(
"/webhooks",
routeTimeout(),
idempotencyMiddleware("webhook"),
validate(webhookCreateBodySchema),
async (req, res, next) => {
try {
const body = req.validated.body;
const ownerIp = clientIpFromRequest(req);
const existingCount = await webhookRepo.countByOwner(ownerIp);
if (existingCount >= config.webhooks.maxPerSubscriber) {
// Distinct from RATE_LIMITED: this is a standing quota on how many
// webhooks a subscriber may own, not a request rate. Waiting and
// retrying will never clear it — the client must delete a webhook.
// owner_ip is deliberately not echoed back in the response details.
return next(
new AppError(
"WEBHOOK_LIMIT_EXCEEDED",
`Webhook limit of ${config.webhooks.maxPerSubscriber} per subscriber exceeded`,
429,
{ limit: config.webhooks.maxPerSubscriber, current: existingCount },
),
);
}
const secret = body.secret || signatureService.generateSecret();
const reachability = await probeReachability(body.url);
const webhook = await webhookRepo.create({
url: body.url,
events: body.events,
secret,
description: body.description,
filters: body.filters,
owner_ip: ownerIp,
});
const response = {
...publicView(webhook),
secret,
secret_warning:
"Store this secret now — it will not be shown again in plaintext.",
reachability: reachability.reachable ? "reachable" : "unreachable",
};
if (!reachability.reachable) {
response.warning = `Webhook target is unreachable during registration: ${reachability.error || "request failed"}`;
}
return res.status(201).json(response);
} catch (err) {
return next(err);
}
},
);
router.get("/webhooks", validatePaginationQuery, async (req, res, next) => {
try {
const { page, limit } = req.validated.query;
const result = await webhookRepo.list(page, limit);
return res.json(
paginateResponse(result.webhooks.map(publicView), result.total, {
page,
limit,
}),
);
} catch (err) {
return next(err);
}
});
router.get("/webhooks/:id", validateRouteIdParams, async (req, res, next) => {
try {
const webhook = await webhookRepo.findById(req.params.id);
if (!webhook)
return next(new AppError("WEBHOOK_NOT_FOUND", "Webhook not found", 404));
return res.json(publicView(webhook));
} catch (err) {
return next(err);
}
});
router.patch(
"/webhooks/:id",
validateRouteIdParams,
validate(webhookPatchBodySchema),
async (req, res, next) => {
try {
const patch = req.validated.body;
const updated = await webhookRepo.update(req.params.id, patch);
if (!updated)
return next(
new AppError("WEBHOOK_NOT_FOUND", "Webhook not found", 404),
);
return res.json(publicView(updated));
} catch (err) {
return next(err);
}
},
);
router.delete(
"/webhooks/:id",
validateRouteIdParams,
async (req, res, next) => {
try {
const deleted = await webhookRepo.remove(req.params.id);
if (!deleted)
return next(
new AppError("WEBHOOK_NOT_FOUND", "Webhook not found", 404),
);
return res.json({ deleted: true, id: req.params.id });
} catch (err) {
return next(err);
}
},
);
// Reduces a delivery's raw failure detail to a generic public category so
// the /test response never leaks low-level network error strings (e.g.
// "ECONNREFUSED ...") or raw upstream HTTP detail to the caller. The raw
// value is still stored on the delivery record and logged server-side for
// operators (#96).
function categorizePublicError(delivery) {
if (!delivery.last_error) return delivery.last_error;
return delivery.response_status != null ? "error_response" : "unreachable";
}
router.post(
"/webhooks/:id/test",
routeTimeout(),
validateRouteIdParams,
testLimit,
async (req, res, next) => {
try {
const delivery = await dispatcher.sendTest(req.params.id);
if (!delivery)
return next(
new AppError("WEBHOOK_NOT_FOUND", "Webhook not found", 404),
);
return res.status(202).json({
delivery_id: delivery.id,
status: delivery.status,
attempts: delivery.attempts,
response_status: delivery.response_status,
last_error: categorizePublicError(delivery),
});
} catch (err) {
return next(err);
}
},
);
router.get(
"/webhooks/:id/deliveries",
validateRouteIdParams,
validate(webhookDeliveriesQuerySchema, "query"),
async (req, res, next) => {
try {
const webhook = await webhookRepo.findById(req.params.id);
if (!webhook)
return next(
new AppError("WEBHOOK_NOT_FOUND", "Webhook not found", 404),
);
const { limit, status } = req.validated.query;
const deliveries = await deliveryRepo.listByWebhook(req.params.id, {
limit,
status,
});
return res.json({ deliveries });
} catch (err) {
return next(err);
}
},
);
module.exports = router;