Files
wekan/server/lib/syncWebhookComment.js

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 };