From 8bbec30e28fc4cb7aba1a4e1e2724acb7ac6d258 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Mon, 3 Nov 2025 14:43:51 -0500 Subject: [PATCH] feat: API v3 calls to crosspost and uncrosspost a topic to and from a category --- public/language/en-GB/error.json | 2 + .../components/schemas/CrosspostObject.yaml | 34 ++++++ public/openapi/write.yaml | 2 + .../write/categories/cid/moderator/uid.yaml | 2 - .../write/categories/cid/privileges.yaml | 2 - .../categories/cid/privileges/privilege.yaml | 4 - .../openapi/write/categories/cid/topics.yaml | 1 - .../openapi/write/topics/tid/crossposts.yaml | 76 +++++++++++++ src/controllers/write/topics.js | 14 +++ src/routes/write/topics.js | 3 + src/topics/tools.js | 107 ++++++++++++++++++ 11 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 public/openapi/components/schemas/CrosspostObject.yaml create mode 100644 public/openapi/write/topics/tid/crossposts.yaml diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index ecb950dff9..140ec2bb76 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -170,6 +170,8 @@ "topic-already-deleted": "This topic has already been deleted", "topic-already-restored": "This topic has already been restored", + "topic-already-crossposted": "This topic has already been cross-posted there.", + "cant-purge-main-post": "You can't purge the main post, please delete the topic instead", "topic-thumbnails-are-disabled": "Topic thumbnails are disabled.", diff --git a/public/openapi/components/schemas/CrosspostObject.yaml b/public/openapi/components/schemas/CrosspostObject.yaml new file mode 100644 index 0000000000..53a4183055 --- /dev/null +++ b/public/openapi/components/schemas/CrosspostObject.yaml @@ -0,0 +1,34 @@ +CrosspostObject: + type: object + properties: + id: + type: string + description: The cross-post ID + cid: + type: object + description: The category id that the topic was cross-posted to + additionalProperties: + oneOf: + - type: string + - type: number + tid: + type: object + description: The topic id that was cross-posted + additionalProperties: + oneOf: + - type: string + - type: number + timestamp: + type: number + uid: + type: object + description: The user id that initiated the cross-post + additionalProperties: + oneOf: + - type: string + - type: number +CrosspostsArray: + type: array + description: A list of crosspost objects + items: + $ref: '#/CrosspostObject' \ No newline at end of file diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index ef17b75737..dfef0aa0cf 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -168,6 +168,8 @@ paths: $ref: 'write/topics/tid/bump.yaml' /topics/{tid}/move: $ref: 'write/topics/tid/move.yaml' + /topics/{tid}/crossposts: + $ref: 'write/topics/tid/crossposts.yaml' /tags/{tag}/follow: $ref: 'write/tags/tag/follow.yaml' /posts/{pid}: diff --git a/public/openapi/write/categories/cid/moderator/uid.yaml b/public/openapi/write/categories/cid/moderator/uid.yaml index 217c3c09b0..3e7223d1d7 100644 --- a/public/openapi/write/categories/cid/moderator/uid.yaml +++ b/public/openapi/write/categories/cid/moderator/uid.yaml @@ -86,7 +86,6 @@ put: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false types: type: object @@ -103,7 +102,6 @@ put: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false types: type: object diff --git a/public/openapi/write/categories/cid/privileges.yaml b/public/openapi/write/categories/cid/privileges.yaml index b450aee682..4760e03f5c 100644 --- a/public/openapi/write/categories/cid/privileges.yaml +++ b/public/openapi/write/categories/cid/privileges.yaml @@ -47,7 +47,6 @@ get: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false isPrivate: type: boolean @@ -65,7 +64,6 @@ get: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false types: type: object diff --git a/public/openapi/write/categories/cid/privileges/privilege.yaml b/public/openapi/write/categories/cid/privileges/privilege.yaml index 06303ac092..9c7cba8882 100644 --- a/public/openapi/write/categories/cid/privileges/privilege.yaml +++ b/public/openapi/write/categories/cid/privileges/privilege.yaml @@ -93,7 +93,6 @@ put: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false groups: type: array @@ -107,7 +106,6 @@ put: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false types: type: object @@ -230,7 +228,6 @@ delete: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false groups: type: array @@ -244,7 +241,6 @@ delete: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false types: type: object diff --git a/public/openapi/write/categories/cid/topics.yaml b/public/openapi/write/categories/cid/topics.yaml index a14664b20c..fd36d8e417 100644 --- a/public/openapi/write/categories/cid/topics.yaml +++ b/public/openapi/write/categories/cid/topics.yaml @@ -71,5 +71,4 @@ get: privileges: type: object additionalProperties: - type: boolean description: A set of privileges with either true or false \ No newline at end of file diff --git a/public/openapi/write/topics/tid/crossposts.yaml b/public/openapi/write/topics/tid/crossposts.yaml new file mode 100644 index 0000000000..212a1f6469 --- /dev/null +++ b/public/openapi/write/topics/tid/crossposts.yaml @@ -0,0 +1,76 @@ +post: + tags: + - topics + summary: crosspost a topic + description: This operation crossposts a topic to another category. + parameters: + - in: path + name: tid + schema: + type: string + required: true + description: a valid topic id + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + cid: + type: number + example: 1 + responses: + '200': + description: Topic successfully crossposted + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + crossposts: + $ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray +delete: + tags: + - topics + summary: uncrossposts a topic + description: This operation uncrossposts a topic from a category. + parameters: + - in: path + name: tid + schema: + type: string + required: true + description: a valid topic id + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + cid: + type: number + example: 1 + responses: + '200': + description: Topic successfully uncrossposted + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + crossposts: + $ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray \ No newline at end of file diff --git a/src/controllers/write/topics.js b/src/controllers/write/topics.js index 06aded5913..16270d75d3 100644 --- a/src/controllers/write/topics.js +++ b/src/controllers/write/topics.js @@ -213,3 +213,17 @@ Topics.move = async (req, res) => { helpers.formatApiResponse(200, res); }; + +Topics.crosspost = async (req, res) => { + const { cid } = req.body; + const crossposts = await topics.tools.crosspost(req.params.tid, cid, req.uid); + + helpers.formatApiResponse(200, res, { crossposts }); +}; + +Topics.uncrosspost = async (req, res) => { + const { cid } = req.body; + const crossposts = await topics.tools.uncrosspost(req.params.tid, cid, req.uid); + + helpers.formatApiResponse(200, res, { crossposts }); +}; diff --git a/src/routes/write/topics.js b/src/routes/write/topics.js index 2b159ee3c0..0ec6fc2509 100644 --- a/src/routes/write/topics.js +++ b/src/routes/write/topics.js @@ -54,5 +54,8 @@ module.exports = function () { setupApiRoute(router, 'put', '/:tid/move', [...middlewares, middleware.assert.topic], controllers.write.topics.move); + setupApiRoute(router, 'post', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.crosspost); + setupApiRoute(router, 'delete', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.uncrosspost); + return router; }; diff --git a/src/topics/tools.js b/src/topics/tools.js index f4ac42e3dd..41db793168 100644 --- a/src/topics/tools.js +++ b/src/topics/tools.js @@ -5,9 +5,11 @@ const _ = require('lodash'); const db = require('../database'); const topics = require('.'); const categories = require('../categories'); +const posts = require('../posts'); const user = require('../user'); const plugins = require('../plugins'); const privileges = require('../privileges'); +const activitypub = require('../activitypub'); const utils = require('../utils'); @@ -311,4 +313,109 @@ module.exports = function (Topics) { db.sortedSetAdd(set, timestamp, tid), ]); }; + + async function getCrossposts(tid) { + const crosspostIds = await db.getSortedSetMembers(`tid:${tid}:crossposts`); + let crossposts = await db.getObjects(crosspostIds.map(id => `crosspost:${id}`)); + crossposts = crossposts.map((crosspost, idx) => { + crosspost.id = crosspostIds[idx]; + return crosspost; + }); + + return crossposts; + } + + topicTools.crosspost = async function (tid, cid, uid) { + // Target cid must exist + if (!utils.isNumber(cid)) { + await activitypub.actors.assert(cid); + } + const exists = await categories.exists(cid); + if (!exists) { + throw new Error('[[error:invalid-cid]]'); + } + + const crossposts = await getCrossposts(tid); + const crosspostedCids = crossposts.map(crosspost => String(crosspost.cid)); + const now = Date.now(); + const crosspostId = utils.generateUUID(); + if (!crosspostedCids.includes(String(cid))) { + const [topicData, pids] = await Promise.all([ + topics.getTopicFields(tid, ['uid', 'cid', 'timestamp']), + topics.getPids(tid), + ]); + let pidTimestamps = await posts.getPostsFields(pids, ['timestamp']); + pidTimestamps = pidTimestamps.map(({ timestamp }) => timestamp); + + if (cid === topicData.cid) { + throw new Error('[[error:invalid-cid]]'); + } + const zsets = [ + `cid:${topicData.cid}:tids`, + `cid:${topicData.cid}:tids:create`, + `cid:${topicData.cid}:tids:lastposttime`, + `cid:${topicData.cid}:uid:${topicData.uid}:tids`, + `cid:${topicData.cid}:tids:votes`, + `cid:${topicData.cid}:tids:posts`, + `cid:${topicData.cid}:tids:views`, + ]; + const scores = await db.sortedSetsScore(zsets, tid); + const bulkAdd = zsets.map((zset, idx) => { + return [zset.replace(`cid:${topicData.cid}`, `cid:${cid}`), scores[idx], tid]; + }); + await Promise.all([ + db.sortedSetAddBulk(bulkAdd), + db.sortedSetAdd(`cid:${cid}:pids`, pidTimestamps, pids), + db.setObject(`crosspost:${crosspostId}`, { uid, tid, cid, timestamp: now }), + db.sortedSetAdd(`tid:${tid}:crossposts`, now, crosspostId), + db.sortedSetAdd(`uid:${uid}:crossposts`, now, crosspostId), + ]); + await categories.onTopicsMoved([cid]); + } else { + throw new Error('[[error:topic-already-crossposted]]'); + } + + return [...crossposts, { id: crosspostId, uid, tid, cid, timestamp: now }]; + }; + + topicTools.uncrosspost = async function (tid, cid, uid) { + let crossposts = await getCrossposts(tid); + const crosspostId = crossposts.reduce((id, { id: _id, cid: _cid, uid: _uid }) => { + if (String(cid) === String(_cid) && String(uid) === String(_uid)) { + id = _id; + } + + return id; + }, null); + if (!crosspostId) { + throw new Error('[[error:invalid-data]]'); + } + + const [author, pids] = await Promise.all([ + topics.getTopicField(tid, 'uid'), + topics.getPids(tid), + ]); + let bulkRemove = [ + `cid:${cid}:tids`, + `cid:${cid}:tids:create`, + `cid:${cid}:tids:lastposttime`, + `cid:${cid}:uid:${author}:tids`, + `cid:${cid}:tids:votes`, + `cid:${cid}:tids:posts`, + `cid:${cid}:tids:views`, + ]; + bulkRemove = bulkRemove.map(zset => [zset, tid]); + bulkRemove.push([`cid:${cid}:pids`, pids]); + + await Promise.all([ + db.sortedSetRemoveBulk(bulkRemove), + db.delete(`crosspost:${crosspostId}`), + db.sortedSetRemove(`tid:${tid}:crossposts`, crosspostId), + db.sortedSetRemove(`uid:${uid}:crossposts`, crosspostId), + ]); + await categories.onTopicsMoved([cid]); + + crossposts = await getCrossposts(tid); + return crossposts; + }; };