mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-05-07 06:37:20 +02:00
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) <aider@aider.chat> * 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) <aider@aider.chat> * feat: support message from both query and body in removeQueuedPost Co-authored-by: aider (ollama/ministral-3:8b) <aider@aider.chat> * 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) <aider@aider.chat> * feat: add query parameter and make request body optional for topic thumbnail deletion Co-authored-by: aider (ollama/ministral-3:8b) <aider@aider.chat> * feat: support query params for thumb path and crosspost id in topics endpoints Co-authored-by: aider (ollama/ministral-3:8b) <aider@aider.chat> * feat: add query params for mute/unmute reason and timestamp Co-authored-by: aider (ollama/ministral-3:8b) <aider@aider.chat> * docs: update unmute API to support query params for reason and until timestamp Co-authored-by: aider (ollama/ministral-3:8b) <aider@aider.chat> * docs: update unmute endpoint to support reason via request body Co-authored-by: aider (ollama/ministral-3:8b) <aider@aider.chat> * feat: add reason parameter support to users.unmute Co-authored-by: aider (ollama/ministral-3:8b) <aider@aider.chat> * 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) <aider@aider.chat>
This commit is contained in:
@@ -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}:
|
||||
|
||||
39
public/openapi/write/categories/cid/follow/actor.yaml
Normal file
39
public/openapi/write/categories/cid/follow/actor.yaml
Normal file
@@ -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: {}
|
||||
@@ -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"
|
||||
51
public/openapi/write/categories/cid/watch/member.yaml
Normal file
51
public/openapi/write/categories/cid/watch/member.yaml
Normal file
@@ -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
|
||||
@@ -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
|
||||
$ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray
|
||||
|
||||
@@ -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
|
||||
description: Path to a topic thumbnail
|
||||
|
||||
@@ -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
|
||||
type: object
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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]]'));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user