From e395fb09199b15cbbd4a264b9ee1d7472ac07f18 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Mon, 20 Apr 2026 09:54:49 -0400 Subject: [PATCH] fix 14064 (#14184) * **feat**: add support for setting watch state via category ID and member UID in route params * **feat**: update category watch/privilege routes and logic to use `member` param instead of `uid`/`member` body field * **feat**: add support for unfollowing via actor parameter in category routes * **feat**: add deprecation warnings for legacy category routes and refactor privilege fetching to inline async call * docs: add openapi specs for new category routes with path parameters Co-authored-by: aider (ollama/ministral-3:8b) * fix: some minor errors in file paths * feat: support path extraction from req.query in assert.path middleware Co-authored-by: aider (ollama/ministral-3:8b) * feat: support message from both query and body in removeQueuedPost Co-authored-by: aider (ollama/ministral-3:8b) * fix: remove ai-added check that path is defined, it is not required * fix: send message as query param instead of in body, when rejecting queued post * lint: comma dangle * `feat(openapi): add optional query parameter support for topic delete endpoints` Co-authored-by: aider (ollama/ministral-3:8b) * feat: add query parameter and make request body optional for topic thumbnail deletion Co-authored-by: aider (ollama/ministral-3:8b) * feat: support query params for thumb path and crosspost id in topics endpoints Co-authored-by: aider (ollama/ministral-3:8b) * feat: add query params for mute/unmute reason and timestamp Co-authored-by: aider (ollama/ministral-3:8b) * docs: update unmute API to support query params for reason and until timestamp Co-authored-by: aider (ollama/ministral-3:8b) * docs: update unmute endpoint to support reason via request body Co-authored-by: aider (ollama/ministral-3:8b) * feat: add reason parameter support to users.unmute Co-authored-by: aider (ollama/ministral-3:8b) * feat: allow unban reason to be sent via req.query * fix: timestamps are unix timestamps not 8601 * docs: mute shouldn't have been updated * chore: remove deprecation warnings --------- Co-authored-by: aider (ollama/ministral-3:8b) --- public/openapi/write.yaml | 6 + .../write/categories/cid/follow/actor.yaml | 39 ++++++ .../cid/privileges/privilege/member.yaml | 132 ++++++++++++++++++ .../write/categories/cid/watch/member.yaml | 51 +++++++ .../openapi/write/topics/tid/crossposts.yaml | 11 +- public/openapi/write/topics/tid/thumbs.yaml | 11 +- public/openapi/write/users/uid/mute.yaml | 20 ++- public/src/client/post-queue.js | 2 +- src/controllers/write/categories.js | 13 +- src/controllers/write/posts.js | 7 +- src/controllers/write/topics.js | 14 +- src/controllers/write/users.js | 12 +- src/middleware/assert.js | 18 ++- src/routes/write/categories.js | 5 +- src/routes/write/topics.js | 2 +- 15 files changed, 314 insertions(+), 29 deletions(-) create mode 100644 public/openapi/write/categories/cid/follow/actor.yaml create mode 100644 public/openapi/write/categories/cid/privileges/privilege/member.yaml create mode 100644 public/openapi/write/categories/cid/watch/member.yaml diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index debdce8a68..7a25ca7c9c 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -130,14 +130,20 @@ paths: $ref: 'write/categories/cid/topics.yaml' /categories/{cid}/watch: $ref: 'write/categories/cid/watch.yaml' + /categories/{cid}/watch/{member}: + $ref: 'write/categories/cid/watch/member.yaml' /categories/{cid}/privileges: $ref: 'write/categories/cid/privileges.yaml' /categories/{cid}/privileges/{privilege}: $ref: 'write/categories/cid/privileges/privilege.yaml' + /categories/{cid}/privileges/{privilege}/{member}: + $ref: 'write/categories/cid/privileges/privilege/member.yaml' /categories/{cid}/moderator/{uid}: $ref: 'write/categories/cid/moderator/uid.yaml' /categories/{cid}/follow: $ref: 'write/categories/cid/follow.yaml' + /categories/{cid}/follow/{actor}: + $ref: 'write/categories/cid/follow/actor.yaml' /topics/: $ref: 'write/topics.yaml' /topics/{tid}: diff --git a/public/openapi/write/categories/cid/follow/actor.yaml b/public/openapi/write/categories/cid/follow/actor.yaml new file mode 100644 index 0000000000..088f2a6e05 --- /dev/null +++ b/public/openapi/write/categories/cid/follow/actor.yaml @@ -0,0 +1,39 @@ +delete: + tags: + - categories + summary: unsynchronize category + description: | + **This operation requires an enabled activitypub integration** + + Removes a "follow" relationship between another activitypub-enabled actor. + Unlike the synchronization request, this does not require an acceptance from the remote end. + + N.B. This method only severs the link for incoming content. + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id + example: 1 + - in: path + name: actor + schema: + type: string + required: true + description: A valid actor uri or webfinger handle + example: https%3A%2F%2Fexample.org%2Ffoobar + responses: + '200': + description: successfully unsynchronized category + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} diff --git a/public/openapi/write/categories/cid/privileges/privilege/member.yaml b/public/openapi/write/categories/cid/privileges/privilege/member.yaml new file mode 100644 index 0000000000..12cc19f759 --- /dev/null +++ b/public/openapi/write/categories/cid/privileges/privilege/member.yaml @@ -0,0 +1,132 @@ +delete: + tags: + - categories + summary: Rescinds category privilege for user + description: This operation rescinds a category privilege for a specific user + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id, `0` for global privileges, `admin` for admin privileges + example: 1 + - in: path + name: privilege + schema: + type: string + required: true + description: The specific privilege you would like to rescind + example: 'groups:ban' + - in: path + name: member + schema: + type: string + required: true + description: A valid user id + example: '1' + responses: + '200': + description: Privilege successfully rescinded + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + labelData: + type: array + items: + type: object + properties: + label: + type: string + description: the name of the privilege displayed in the ACP dashboard + type: + type: string + description: type of the privilege (one of viewing, posting, moderation or other) + users: + type: array + items: + type: object + properties: + uid: + type: number + description: A user identifier + example: 1 + username: + type: string + description: A friendly name for a given user account + example: Dragon Fruit + displayname: + type: string + description: This is either username or fullname depending on forum and user settings + example: Dragon Fruit + picture: + type: string + description: A URL pointing to a picture to be used as the user's avatar + example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80' + nullable: true + 'icon:text': + type: string + description: A single-letter representation of a username. This is used in the auto-generated icon given to users without an avatar + example: D + 'icon:bgColor': + type: string + description: A six-character hexadecimal colour code assigned to the user. This value is used in conjunction with `icon:text` for the user's auto-generated icon + example: '#9c27b0' + banned: + type: number + description: A Boolean representing whether a user is banned or not + example: 0 + banned_until_readable: + type: string + description: An ISO 8601 formatted date string representing the moment a ban will be lifted, or the words "Not Banned" + example: Not Banned + privileges: + type: object + additionalProperties: + description: A set of privileges with either true or false + groups: + type: array + items: + type: object + properties: + name: + type: string + nameEscaped: + type: string + privileges: + type: object + additionalProperties: + description: A set of privileges with either true or false + types: + type: object + description: Each privilege will have a key in this object, the value will be the type of the privilege (viewing, posting, moderation or other) + isPrivate: + type: boolean + isSystem: + type: boolean + keys: + type: object + properties: + users: + type: array + items: + type: string + description: "Privilege name" + groups: + type: array + items: + type: string + description: "Privilege name" + columnCountUserOther: + type: number + description: "The number of additional user privileges added by plugins" + columnCountGroupOther: + type: number + description: "The number of additional group privileges added by plugins" diff --git a/public/openapi/write/categories/cid/watch/member.yaml b/public/openapi/write/categories/cid/watch/member.yaml new file mode 100644 index 0000000000..8776a58eee --- /dev/null +++ b/public/openapi/write/categories/cid/watch/member.yaml @@ -0,0 +1,51 @@ +delete: + tags: + - categories + summary: update watch state for specific user + description: | + This operation changes the watch state for a specific user in the category. + Unlike the general watch state update, this route takes the user ID directly in the path. + + Note that a category can be watched, not watched, or ignored: + + * A category that is watched will have topics that show up in both `/unread` and `/recent` + * A category that is *not* watched will have topics that show up in `/recent` but not `/unread` + * A category that is ignored will not have topics that show up in either route. + + This API call does not pertain to notifications for new topics in categories. + That behaviour is handled by a third-party plugin — nodebb-plugin-category-notifications + + Additionally, when a category's watch state is updated, all of that category's children also have their watch states updated. + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id + example: 1 + - in: path + name: member + schema: + type: string + required: true + description: a valid user id + example: 1 + responses: + '200': + description: categories watch state successfully updated + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + modified: + type: array + description: A list of cids that have had their watch states modified. + items: + type: string diff --git a/public/openapi/write/topics/tid/crossposts.yaml b/public/openapi/write/topics/tid/crossposts.yaml index f292292e0a..10de56505c 100644 --- a/public/openapi/write/topics/tid/crossposts.yaml +++ b/public/openapi/write/topics/tid/crossposts.yaml @@ -77,8 +77,15 @@ delete: required: true description: a valid topic id example: 1 + - in: query + name: cid + schema: + type: integer + required: false + description: Category ID of the crosspost to remove (alternative to request body) + example: 1 requestBody: - required: true + required: false content: application/json: schema: @@ -101,4 +108,4 @@ delete: type: object properties: crossposts: - $ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray \ No newline at end of file + $ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray diff --git a/public/openapi/write/topics/tid/thumbs.yaml b/public/openapi/write/topics/tid/thumbs.yaml index 5d28264266..3ce9aafe9f 100644 --- a/public/openapi/write/topics/tid/thumbs.yaml +++ b/public/openapi/write/topics/tid/thumbs.yaml @@ -96,8 +96,15 @@ delete: required: true description: a valid topic id example: 1 + - in: query + name: path + schema: + type: string + required: false + description: Relative path to the topic thumbnail (alternative to request body) + example: files/test.png requestBody: - required: true + required: false content: application/json: schema: @@ -131,4 +138,4 @@ delete: type: string url: type: string - description: Path to a topic thumbnail \ No newline at end of file + description: Path to a topic thumbnail diff --git a/public/openapi/write/users/uid/mute.yaml b/public/openapi/write/users/uid/mute.yaml index 7fa84c9b22..f5333946d0 100644 --- a/public/openapi/write/users/uid/mute.yaml +++ b/public/openapi/write/users/uid/mute.yaml @@ -47,6 +47,24 @@ delete: required: true description: uid of the user to unmute example: 2 + - name: reason + in: query + schema: + type: string + nullable: true + description: Reason for the unmute + example: Reason for the unmute + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: + type: string + nullable: true + description: Reason for the unmute + example: Reason for the unmute responses: '200': description: successfully unmuted user @@ -58,4 +76,4 @@ delete: status: $ref: ../../../components/schemas/Status.yaml#/Status response: - type: object \ No newline at end of file + type: object diff --git a/public/src/client/post-queue.js b/public/src/client/post-queue.js index 78d804b0bb..1a637b6e1b 100644 --- a/public/src/client/post-queue.js +++ b/public/src/client/post-queue.js @@ -272,7 +272,7 @@ define('forum/post-queue', [ async function doAction(action, id, message = '') { const actionsMap = { accept: () => api.post(`/posts/queue/${id}`, {}), - reject: () => api.del(`/posts/queue/${id}`, { message }), + reject: () => api.del(`/posts/queue/${id}?message=${encodeURIComponent(message)}`, {}), notify: () => api.post(`/posts/queue/${id}/notify`, { message }), }; if (actionsMap[action]) { diff --git a/src/controllers/write/categories.js b/src/controllers/write/categories.js index 996a9d386a..af9ef89e4a 100644 --- a/src/controllers/write/categories.js +++ b/src/controllers/write/categories.js @@ -61,7 +61,8 @@ Categories.getTopics = async (req, res) => { Categories.setWatchState = async (req, res) => { const { cid } = req.params; - let { uid, state } = req.body; + const uid = req.params.member || req.body.uid; + let { state } = req.body; if (req.method === 'DELETE') { // DELETE is always setting state to system default in acp @@ -73,22 +74,21 @@ Categories.setWatchState = async (req, res) => { } const { cids: modified } = await api.categories.setWatchState(req, { cid, state, uid }); - helpers.formatApiResponse(200, res, { modified }); }; Categories.getPrivileges = async (req, res) => { - const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid }); - helpers.formatApiResponse(200, res, privilegeSet); + helpers.formatApiResponse(200, res, await api.categories.getPrivileges(req, { cid: req.params.cid })); }; Categories.setPrivilege = async (req, res) => { const { cid, privilege } = req.params; + const member = req.params.member || req.body.member; await api.categories.setPrivilege(req, { cid, privilege, - member: req.body.member, + member, set: req.method === 'PUT', }); @@ -117,13 +117,12 @@ Categories.follow = async (req, res, next) => { } await activitypub.out.follow('cid', id, actor); - helpers.formatApiResponse(200, res, {}); }; Categories.unfollow = async (req, res, next) => { - const { actor } = req.body; const id = parseInt(req.params.cid, 10); + const actor = req.params.actor || req.body.actor; if (!id) { // disallow cid 0 return next(); diff --git a/src/controllers/write/posts.js b/src/controllers/write/posts.js index 0c0a480dfb..e6d1b6733b 100644 --- a/src/controllers/write/posts.js +++ b/src/controllers/write/posts.js @@ -196,7 +196,10 @@ Posts.acceptQueuedPost = async (req, res) => { }; Posts.removeQueuedPost = async (req, res) => { - await api.posts.removeQueuedPost(req, { id: req.params.id, message: req.body.message }); + await api.posts.removeQueuedPost(req, { + id: req.params.id, + message: req.query.message || req.body.message, + }); helpers.formatApiResponse(200, res); }; @@ -217,4 +220,4 @@ Posts.changeOwner = async (req, res) => { uid: req.body.uid, }); helpers.formatApiResponse(200, res); -}; \ No newline at end of file +}; diff --git a/src/controllers/write/topics.js b/src/controllers/write/topics.js index 868fc08e8e..9399903bb0 100644 --- a/src/controllers/write/topics.js +++ b/src/controllers/write/topics.js @@ -153,7 +153,13 @@ Topics.addThumb = async (req, res) => { Topics.deleteThumb = async (req, res) => { - if (!req.body.path.startsWith('http')) { + if (!req.body.path && !req.query.path) { + return helpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]')); + } + + const path = req.body.path || req.query.path; + + if (!path.startsWith('http')) { await middleware.assert.path(req, res, () => {}); if (res.headersSent) { return; @@ -162,7 +168,7 @@ Topics.deleteThumb = async (req, res) => { await api.topics.deleteThumb(req, { tid: req.params.tid, - path: req.body.path, + path: path, }); helpers.formatApiResponse(200, res, await topics.thumbs.get(req.params.tid)); }; @@ -229,9 +235,9 @@ Topics.crosspost = async (req, res) => { }; Topics.uncrosspost = async (req, res) => { - const { cid } = req.body; + const cid = req.body.cid || req.query.cid; const crossposts = await topics.crossposts.remove(req.params.tid, cid, req.uid); - await activitypub.out.undo.announce('uid', req.uid, req.params.tid); + await activitypub.out.undo.announce('uid', req.uid, req.params.tid); helpers.formatApiResponse(200, res, { crossposts }); }; diff --git a/src/controllers/write/users.js b/src/controllers/write/users.js index 935f6d427f..839c3105ad 100644 --- a/src/controllers/write/users.js +++ b/src/controllers/write/users.js @@ -119,7 +119,11 @@ Users.ban = async (req, res) => { }; Users.unban = async (req, res) => { - await api.users.unban(req, { ...req.body, uid: req.params.uid }); + const params = { + uid: req.params.uid, + reason: req.query.reason || req.body.reason, + }; + await api.users.unban(req, params); helpers.formatApiResponse(200, res); }; @@ -129,7 +133,11 @@ Users.mute = async (req, res) => { }; Users.unmute = async (req, res) => { - await api.users.unmute(req, { ...req.body, uid: req.params.uid }); + const params = { + uid: req.params.uid, + reason: req.query.reason || req.body.reason, + }; + await api.users.unmute(req, params); helpers.formatApiResponse(200, res); }; diff --git a/src/middleware/assert.js b/src/middleware/assert.js index 8d41d7365b..f7cf7bdba0 100644 --- a/src/middleware/assert.js +++ b/src/middleware/assert.js @@ -82,17 +82,23 @@ Assert.flag = helpers.try(async (req, res, next) => { }); Assert.path = helpers.try(async (req, res, next) => { + // Get path from either query or body + let _path = req.body.path; + if (!_path && req.query.path) { + _path = req.query.path; + } + // file: URL support - if (req.body.path.startsWith('file:///')) { - req.body.path = new URL(req.body.path).pathname; + if (_path.startsWith('file:///')) { + _path = new URL(_path).pathname; } // Strip upload_url if found - if (req.body.path.startsWith(nconf.get('upload_url'))) { - req.body.path = req.body.path.slice(nconf.get('upload_url').length); + if (_path.startsWith(nconf.get('upload_url'))) { + _path = _path.slice(nconf.get('upload_url').length); } - const pathToFile = path.join(nconf.get('upload_path'), req.body.path); + const pathToFile = path.join(nconf.get('upload_path'), _path); res.locals.cleanedPath = pathToFile; // Guard against path traversal @@ -153,7 +159,7 @@ Assert.message = helpers.try(async (req, res, next) => { if ( !(await messaging.messageExists(req.params.mid)) || - !(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid)) + !(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid)) ) { return controllerHelpers.formatApiResponse(400, res, new Error('[[error:invalid-mid]]')); } diff --git a/src/routes/write/categories.js b/src/routes/write/categories.js index 066e75bb96..09af5c5e9a 100644 --- a/src/routes/write/categories.js +++ b/src/routes/write/categories.js @@ -23,16 +23,19 @@ module.exports = function () { setupApiRoute(router, 'put', '/:cid/watch', [...middlewares, middleware.assert.category], controllers.write.categories.setWatchState); setupApiRoute(router, 'delete', '/:cid/watch', [...middlewares, middleware.assert.category], controllers.write.categories.setWatchState); + setupApiRoute(router, 'delete', '/:cid/watch/:member', [...middlewares, middleware.assert.category], controllers.write.categories.setWatchState); setupApiRoute(router, 'get', '/:cid/privileges', [...middlewares], controllers.write.categories.getPrivileges); setupApiRoute(router, 'put', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); - setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); + setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares], controllers.write.categories.setPrivilege); + setupApiRoute(router, 'delete', '/:cid/privileges/:privilege/:member', [...middlewares], controllers.write.categories.setPrivilege); setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator); setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator); setupApiRoute(router, 'put', '/:cid/follow', [...middlewares, middleware.activitypub.enabled, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.follow); setupApiRoute(router, 'delete', '/:cid/follow', [...middlewares, middleware.activitypub.enabled, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.unfollow); + setupApiRoute(router, 'delete', '/:cid/follow/:actor', [...middlewares, middleware.activitypub.enabled, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.unfollow); return router; }; diff --git a/src/routes/write/topics.js b/src/routes/write/topics.js index 107fdac8bd..9fdbca9081 100644 --- a/src/routes/write/topics.js +++ b/src/routes/write/topics.js @@ -42,7 +42,7 @@ module.exports = function () { ], controllers.write.topics.addThumb); - setupApiRoute(router, 'delete', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['path'])], controllers.write.topics.deleteThumb); + setupApiRoute(router, 'delete', '/:tid/thumbs', [...middlewares], controllers.write.topics.deleteThumb); setupApiRoute(router, 'put', '/:tid/thumbs/order', [...middlewares, middleware.checkRequired.bind(null, ['path', 'order'])], controllers.write.topics.reorderThumbs); setupApiRoute(router, 'get', '/:tid/events', [middleware.assert.topic], controllers.write.topics.getEvents);