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