mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
79 lines
4.4 KiB
JavaScript
79 lines
4.4 KiB
JavaScript
'use strict';
|
|
const { EJSON } = require('bson');
|
|
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
|
|
const { notificationActivityIdentity } = require('./syncNotificationPlan');
|
|
const { validateWebhookPlan, deliveryId } = require('./syncWebhookPlan');
|
|
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
|
|
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
const keys = (value, expected) => value && typeof value === 'object' && !Array.isArray(value) &&
|
|
Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key));
|
|
|
|
// Persist HTTP acceptance before applying a bidirectional response. A crash in
|
|
// response processing can then resume without sending the webhook again.
|
|
// The caller owns the operation lease; completeResponse must durably acknowledge
|
|
// its own effects, not merely return after an unrecorded comment update.
|
|
async function deliverStoredWebhookHttp({ item, responses, requestHttp, assertCurrent, assertTarget, completeResponse }) {
|
|
item = copy(item);
|
|
if (!keys(item, ['activity', 'target', 'request', 'deliveryId'])) throw new Error('sync-webhook-http-invalid');
|
|
const { activity, target, request } = item;
|
|
validateWebhookPlan({ version: 1, ...notificationActivityIdentity(activity), targets: [target] }, activity);
|
|
const expectedRequest = target.request && { ...target.request,
|
|
headers: { ...target.request.headers, 'X-Wekan-Delivery-Id': item.deliveryId } };
|
|
if (!expectedRequest || item.deliveryId !== deliveryId(activity._id, target.integrationId) ||
|
|
canonical(request) !== canonical(expectedRequest) ||
|
|
![requestHttp, assertCurrent, assertTarget].every(fn => typeof fn === 'function') ||
|
|
(request.is2way && typeof completeResponse !== 'function')) throw new Error('sync-webhook-http-invalid');
|
|
const requestHash = sha256(canonical(item)), _id = item.deliveryId;
|
|
async function guard() {
|
|
await assertCurrent();
|
|
if (await assertTarget(copy(target), copy(activity)) !== true) throw new Error('sync-webhook-target-denied');
|
|
await assertCurrent();
|
|
}
|
|
async function read() {
|
|
const row = await responses.findOne({ _id });
|
|
if (!row) return null;
|
|
if (!keys(row, ['_id', 'version', 'requestHash', 'status', 'body', 'checksum']) || row._id !== _id ||
|
|
row.version !== 1 || row.requestHash !== requestHash || !Number.isInteger(row.status) ||
|
|
row.status < 200 || row.status >= 300 || typeof row.body !== 'string' ||
|
|
Buffer.byteLength(row.body, 'utf8') > MAX_RESPONSE_BYTES ||
|
|
row.checksum !== sha256(canonical({ status: row.status, body: row.body }))) {
|
|
throw new Error('sync-webhook-response-invalid');
|
|
}
|
|
return copy(row);
|
|
}
|
|
await guard();
|
|
let saved = await read();
|
|
if (!saved) {
|
|
await guard();
|
|
// The production adapter binds requestHttp to fetchSafe. DNS pinning,
|
|
// private-IP denial and redirect refusal apply to every new attempt.
|
|
const response = await requestHttp(request.url, { method: 'POST', headers: copy(request.headers), body: request.body,
|
|
timeoutMs: 30000, totalTimeoutMs: 30000, maxResponseBytes: MAX_RESPONSE_BYTES, maxRedirects: 0 });
|
|
if (!response || !Number.isInteger(response.status) || response.status < 200 || response.status >= 300) {
|
|
throw new Error('sync-webhook-http-rejected');
|
|
}
|
|
const body = await response.text();
|
|
if (typeof body !== 'string' || Buffer.byteLength(body, 'utf8') > MAX_RESPONSE_BYTES) {
|
|
throw new Error('sync-webhook-response-too-large');
|
|
}
|
|
const candidate = { _id, version: 1, requestHash, status: response.status, body,
|
|
checksum: sha256(canonical({ status: response.status, body })) };
|
|
await guard();
|
|
let failure;
|
|
try { await responses.insertOne(candidate); } catch (error) { failure = error; }
|
|
saved = await read();
|
|
if (!saved) throw failure || new Error('sync-webhook-response-unconfirmed');
|
|
}
|
|
await guard();
|
|
if (request.is2way) {
|
|
let data;
|
|
try { data = JSON.parse(saved.body); } catch { data = null; }
|
|
// Match ordinary webhooks: non-JSON or false/null responses carry no action.
|
|
if (data && await completeResponse({ deliveryId: _id, data: copy(data), activity: copy(activity),
|
|
target: copy(target), assertCurrent: guard }) !== _id) throw new Error('sync-webhook-response-unconfirmed');
|
|
}
|
|
await guard();
|
|
return _id;
|
|
}
|
|
module.exports = { deliverStoredWebhookHttp, MAX_RESPONSE_BYTES };
|