From ff0c289e1da5baf77f732b911b416419e342c08a Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Tue, 16 Jul 2024 11:37:38 -0400 Subject: [PATCH] feat: #12695 Topic Synchronization via resolvable `context` - Generation of a context collection digest via object ids - Sending of said digest in ETag header - Parsing of digests via If-None-Match header - Update note assertion logic to handle 304 response --- src/activitypub/contexts.js | 24 +++++++++++++++++-- src/activitypub/helpers.js | 19 +++++++++++++++ src/activitypub/notes.js | 6 ++++- src/controllers/activitypub/actors.js | 33 ++++++++++++++++++++++----- 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/activitypub/contexts.js b/src/activitypub/contexts.js index c84ac30bc8..b93cd15f33 100644 --- a/src/activitypub/contexts.js +++ b/src/activitypub/contexts.js @@ -2,7 +2,9 @@ const winston = require('winston'); +const db = require('../database'); const posts = require('../posts'); +const topics = require('../topics'); const activitypub = module.parent.exports; const Contexts = module.exports; @@ -13,20 +15,38 @@ Contexts.get = async (uid, id) => { let context; let type; + // Generate digest for If-None-Match if locally cached + const tid = await posts.getPostField(id, 'tid'); + const headers = {}; + if (tid) { + const [mainPid, pids] = await Promise.all([ + topics.getTopicField(tid, 'mainPid'), + db.getSortedSetMembers(`tid:${tid}:posts`), + ]); + pids.push(mainPid); + const digest = activitypub.helpers.generateDigest(new Set(pids)); + headers['If-None-Match'] = `"${digest}"`; + } + try { - ({ context } = await activitypub.get('uid', uid, id)); + ({ context } = await activitypub.get('uid', uid, id, { headers })); if (!context) { winston.verbose(`[activitypub/context] ${id} contains no context.`); return false; } ({ type } = await activitypub.get('uid', uid, context)); } catch (e) { + if (e.code === 'ap_get_304') { + winston.verbose(`[activitypub/context] ${id} context unchanged.`); + return { tid }; + } + winston.verbose(`[activitypub/context] ${id} context not resolvable.`); return false; } if (acceptableTypes.includes(type)) { - return context; + return { context }; } return false; diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index 23830ed060..78a1a0d06e 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -4,6 +4,7 @@ const { generateKeyPairSync } = require('crypto'); const nconf = require('nconf'); const validator = require('validator'); const cheerio = require('cheerio'); +const crypto = require('crypto'); const meta = require('../meta'); const posts = require('../posts'); @@ -19,6 +20,7 @@ const webfingerCache = ttl({ max: 5000, ttl: 1000 * 60 * 60 * 24, // 24 hours }); +const sha256 = payload => crypto.createHash('sha256').update(payload).digest('hex'); const Helpers = module.exports; @@ -403,3 +405,20 @@ Helpers.generateCollection = async ({ set, method, page, perPage, url }) => { return object; }; + +Helpers.generateDigest = (set) => { + if (!(set instanceof Set)) { + throw new Error('[[error:invalid-data]]'); + } + + return Array + .from(set) + .map(item => sha256(item)) + .reduce((memo, cur) => { + const a = Buffer.from(memo, 'hex'); + const b = Buffer.from(cur, 'hex'); + // eslint-disable-next-line no-bitwise + const result = a.map((x, i) => x ^ b[i]); + return result.toString('hex'); + }); +}; diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js index 5e396efa62..315622acea 100644 --- a/src/activitypub/notes.js +++ b/src/activitypub/notes.js @@ -43,7 +43,11 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => { let chain; const context = await activitypub.contexts.get(uid, id); - if (context) { + if (context.tid) { + unlock(id); + const { tid } = context; + return { tid, count: 0 }; + } else if (context.context) { chain = Array.from(await activitypub.contexts.getItems(uid, context)); } else { // Fall back to inReplyTo traversal diff --git a/src/controllers/activitypub/actors.js b/src/controllers/activitypub/actors.js index 3ce4df4ab2..beaf346e1b 100644 --- a/src/controllers/activitypub/actors.js +++ b/src/controllers/activitypub/actors.js @@ -2,6 +2,7 @@ const nconf = require('nconf'); +const db = require('../../database'); const meta = require('../../meta'); const privileges = require('../../privileges'); const posts = require('../../posts'); @@ -100,13 +101,33 @@ Actors.topic = async function (req, res, next) { const perPage = meta.config.postsPerPage; const { cid, titleRaw: name, mainPid, slug } = await topics.getTopicFields(req.params.tid, ['cid', 'title', 'mainPid', 'slug']); try { - const collection = await activitypub.helpers.generateCollection({ - set: `tid:${req.params.tid}:posts`, - method: posts.getPidsFromSet, - page, - perPage, - url: `${nconf.get('url')}/topic/${req.params.tid}`, + let [collection, pids] = await Promise.all([ + activitypub.helpers.generateCollection({ + set: `tid:${req.params.tid}:posts`, + method: posts.getPidsFromSet, + page, + perPage, + url: `${nconf.get('url')}/topic/${req.params.tid}`, + }), + db.getSortedSetMembers(`tid:${req.params.tid}:posts`), + ]); + + // Generate digest for ETag + pids.push(mainPid); + pids = pids.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid)); + const digest = activitypub.helpers.generateDigest(new Set(pids)); + const ifNoneMatch = req.get('If-None-Match').split(',').map((tag) => { + tag = tag.trim(); + if (tag.startsWith('"') && tag.endsWith('"')) { + return tag.slice(1, tag.length - 1); + } + + return tag; }); + if (ifNoneMatch.includes(digest)) { + return res.sendStatus(304); + } + res.set('ETag', digest); // Convert pids to urls collection.totalItems += 1;