mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
151 lines
8.2 KiB
JavaScript
151 lines
8.2 KiB
JavaScript
'use strict';
|
|
const { randomUUID } = require('node:crypto');
|
|
const { EJSON, calculateObjectSize } = require('bson');
|
|
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
|
|
const { exactFieldSelector } = require('../../models/lib/exactFieldSelector');
|
|
const { notificationActivityIdentity } = require('./syncNotificationPlan');
|
|
const { deliveryId } = require('./syncWebhookPlan');
|
|
const PENDING = 'webhookResponsePending';
|
|
const REVISION = 'webhookResponseRevision';
|
|
const FIELDS = ['_id', 'boardId', 'cardId', 'text', 'createdAt', 'modifiedAt', 'userId'];
|
|
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
|
|
const keys = (value, expected) => value && typeof value === 'object' && !Array.isArray(value) &&
|
|
Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key));
|
|
const text = value => typeof value === 'string' && value.length > 0 && value.length <= 1024;
|
|
const date = value => value instanceof Date && Number.isFinite(value.getTime());
|
|
const fail = () => { throw new Error('sync-webhook-comment-plan-invalid'); };
|
|
function contextIdentity(context) {
|
|
notificationActivityIdentity(context.activity);
|
|
if (!text(context.target?.integrationId) || !text(context.target.integrationBoardId) ||
|
|
context.target.request?.is2way !== true) fail();
|
|
return { deliveryId: deliveryId(context.activity._id, context.target.integrationId),
|
|
contextHash: sha256(canonical(context)) };
|
|
}
|
|
// An invalid/inapplicable reply is an explicit no-action plan. Persist it so a
|
|
// later-created comment cannot unexpectedly become the target on replay.
|
|
function prepareWebhookCommentPlan({ context, comment, modifiedAt }) {
|
|
context = copy(context);
|
|
const plan = { version: 1, ...contextIdentity(context), change: null };
|
|
const data = context.data;
|
|
if (!data || typeof data !== 'object' || Array.isArray(data) ||
|
|
!['boardId', 'cardId', 'commentId'].every(key => text(data[key])) ||
|
|
typeof data.comment !== 'string' || !data.comment ||
|
|
data.boardId !== context.target.integrationBoardId || data.boardId !== context.activity.boardId ||
|
|
!comment || comment._id !== data.commentId || comment.boardId !== data.boardId || comment.cardId !== data.cardId) return plan;
|
|
const before = {};
|
|
for (const field of FIELDS) if (Object.hasOwn(comment, field)) before[field] = copy(comment[field]);
|
|
plan.change = { before, text: data.comment, modifiedAt: copy(modifiedAt) };
|
|
validateWebhookCommentPlan(plan, context);
|
|
return plan;
|
|
}
|
|
function validateWebhookCommentPlan(plan, context) {
|
|
const identity = contextIdentity(context);
|
|
if (!keys(plan, ['version', 'deliveryId', 'contextHash', 'change']) || plan.version !== 1 ||
|
|
plan.deliveryId !== identity.deliveryId || plan.contextHash !== identity.contextHash) fail();
|
|
if (plan.change !== null) {
|
|
const { before, text: next, modifiedAt } = plan.change;
|
|
const data = context.data;
|
|
if (!keys(plan.change, ['before', 'text', 'modifiedAt']) || !before || Array.isArray(before) ||
|
|
Object.keys(before).some(key => !FIELDS.includes(key)) ||
|
|
!['_id', 'boardId', 'cardId'].every(key => text(before[key])) || typeof before.text !== 'string' ||
|
|
!date(before.createdAt) || !date(modifiedAt) ||
|
|
(Object.hasOwn(before, 'modifiedAt') && !date(before.modifiedAt)) ||
|
|
(Object.hasOwn(before, 'userId') && !text(before.userId)) ||
|
|
typeof next !== 'string' || !next || before._id !== data?.commentId || before.cardId !== data?.cardId ||
|
|
before.boardId !== data?.boardId || next !== data.comment ||
|
|
before.boardId !== context.activity.boardId || before.boardId !== context.target.integrationBoardId) fail();
|
|
}
|
|
if (calculateObjectSize(plan) > 4 * 1024 * 1024) fail();
|
|
return true;
|
|
}
|
|
async function ensureWebhookCommentPlan({ plans, context, build, assertCurrent }) {
|
|
context = copy(context);
|
|
const { deliveryId: _id } = contextIdentity(context);
|
|
if (![build, assertCurrent].every(fn => typeof fn === 'function')) fail();
|
|
async function read() {
|
|
const row = await plans.findOne({ _id });
|
|
if (!row) return null;
|
|
if (!keys(row, ['_id', 'plan', 'checksum']) || row._id !== _id) fail();
|
|
validateWebhookCommentPlan(row.plan, context);
|
|
if (row.checksum !== sha256(canonical(row.plan))) fail();
|
|
return copy(row.plan);
|
|
}
|
|
await assertCurrent();
|
|
let saved = await read();
|
|
if (!saved) {
|
|
const plan = copy(await build(copy(context)));
|
|
validateWebhookCommentPlan(plan, context);
|
|
await assertCurrent();
|
|
let failure;
|
|
try { await plans.insertOne({ _id, plan, checksum: sha256(canonical(plan)) }); } catch (error) { failure = error; }
|
|
saved = await read();
|
|
if (!saved) throw failure || new Error('sync-webhook-comment-plan-unconfirmed');
|
|
}
|
|
await assertCurrent();
|
|
return saved;
|
|
}
|
|
// Internal raw-driver stage: before enabling it, the application must keep both
|
|
// marker fields private/client-immutable, register private plans/receipts and
|
|
// provide live board/card/comment access checks plus the operation lease guard.
|
|
// No replacement, upsert, deletion or ordinary comment hook is performed here.
|
|
async function applyWebhookCommentPlan({ comments, receipts, plan, context, assertCurrent }) {
|
|
plan = copy(plan); context = copy(context);
|
|
validateWebhookCommentPlan(plan, context);
|
|
if (typeof assertCurrent !== 'function') fail();
|
|
const receipt = { _id: plan.deliveryId, version: 1, checksum: sha256(canonical(plan)) };
|
|
async function guard() { await assertCurrent(copy(plan), copy(context)); }
|
|
async function readReceipt() {
|
|
const row = await receipts.findOne({ _id: receipt._id });
|
|
if (row && canonical(row) !== canonical(receipt)) throw new Error('sync-webhook-comment-receipt-invalid');
|
|
return !!row;
|
|
}
|
|
async function saveReceipt() {
|
|
await guard();
|
|
let failure;
|
|
try { await receipts.insertOne(receipt); } catch (error) { failure = error; }
|
|
if (!await readReceipt()) throw failure || new Error('sync-webhook-comment-receipt-unconfirmed');
|
|
await guard();
|
|
}
|
|
await guard();
|
|
const confirmed = await readReceipt();
|
|
if (plan.change === null) { if (!confirmed) await saveReceipt(); await guard(); return receipt._id; }
|
|
const { before, text: next, modifiedAt } = plan.change;
|
|
const marker = { ...receipt, commentId: before._id };
|
|
const selector = { _id: before._id, boardId: before.boardId, cardId: before.cardId };
|
|
function revision(row) {
|
|
if (Object.hasOwn(row, REVISION) && !text(row[REVISION])) throw new Error('sync-webhook-comment-revision-invalid');
|
|
return Object.hasOwn(row, REVISION) ? row[REVISION] : { $exists: false };
|
|
}
|
|
let current = await comments.findOne(selector), failure;
|
|
if (confirmed && (!current || canonical(current[PENDING]) !== canonical(marker))) {
|
|
await guard(); return receipt._id;
|
|
}
|
|
if (!current) throw new Error('sync-webhook-comment-missing');
|
|
if (!Object.hasOwn(current, PENDING)) {
|
|
await guard();
|
|
try {
|
|
await comments.updateOne({ ...exactFieldSelector(before, FIELDS), [PENDING]: { $exists: false }, [REVISION]: revision(current) },
|
|
{ $set: { text: next, modifiedAt, [PENDING]: marker, [REVISION]: randomUUID() } });
|
|
} catch (error) { failure = error; }
|
|
current = await comments.findOne(selector);
|
|
}
|
|
if (!current || canonical(current[PENDING]) !== canonical(marker)) {
|
|
if (await readReceipt()) { await guard(); return receipt._id; }
|
|
throw failure || new Error('sync-webhook-comment-conflict');
|
|
}
|
|
// The marker proves the text write even if a user subsequently edits or clears
|
|
// the text. Receipt recovery never puts the captured text back.
|
|
await guard();
|
|
if (!await readReceipt()) await saveReceipt();
|
|
await guard();
|
|
try {
|
|
await comments.updateOne({ ...selector, [PENDING]: marker, [REVISION]: revision(current) },
|
|
{ $unset: { [PENDING]: '' }, $set: { [REVISION]: randomUUID() } });
|
|
} catch (error) { failure = error; }
|
|
const remaining = await comments.findOne(selector);
|
|
if (remaining && canonical(remaining[PENDING]) === canonical(marker)) throw failure || new Error('sync-webhook-comment-cleanup-unconfirmed');
|
|
await guard();
|
|
return receipt._id;
|
|
}
|
|
module.exports = { prepareWebhookCommentPlan, validateWebhookCommentPlan, ensureWebhookCommentPlan, applyWebhookCommentPlan, PENDING, REVISION };
|