diff --git a/public/openapi/read/post-queue.yaml b/public/openapi/read/post-queue.yaml index 0ecb95500c..9bd93903c4 100644 --- a/public/openapi/read/post-queue.yaml +++ b/public/openapi/read/post-queue.yaml @@ -1,7 +1,7 @@ get: tags: - admin - summary: Get flag data + summary: Get post queue responses: "200": description: "" @@ -42,6 +42,8 @@ get: description: A user identifier type: type: string + canEdit: + type: boolean data: type: object properties: diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index f7012e77a8..5f58bdfbf1 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -202,6 +202,10 @@ paths: $ref: 'write/posts/pid/diffs/timestamp.yaml' /posts/{pid}/replies: $ref: 'write/posts/pid/replies.yaml' + /posts/queue/{id}: + $ref: 'write/posts/queue/id.yaml' + /posts/queue/{id}/notify: + $ref: 'write/posts/queue/notify.yaml' /chats/: $ref: 'write/chats.yaml' /chats/unread: diff --git a/public/openapi/write/posts/queue/id.yaml b/public/openapi/write/posts/queue/id.yaml new file mode 100644 index 0000000000..00bc01d303 --- /dev/null +++ b/public/openapi/write/posts/queue/id.yaml @@ -0,0 +1,92 @@ +post: + summary: Accept a queued post + tags: + - QueuedPosts + parameters: + - in: path + name: id + schema: + type: string + required: true + description: a valid queued post id + example: 2 + responses: + '200': + description: post successfully accepted + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + type: + type: string + pid: + type: number + tid: + type: number + '400': + description: Bad request, invalid post id +delete: + summary: Remove a queued post + tags: + - QueuedPosts + parameters: + - name: id + in: path + required: true + schema: + type: string + example: 'topic-12345' + responses: + '200': + description: Post removed successfully + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + '400': + description: Bad request, invalid post id +put: + summary: Edit a queued post + tags: + - QueuedPosts + parameters: + - name: id + in: path + required: true + schema: + type: string + example: 'topic-12345' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + example: This is a test reply + cid: + type: number + description: Category ID to which the post belongs + title: + type: string + description: Updated Post Title + responses: + '200': + description: Post edited successfully + '400': + description: Bad request, invalid post id + + diff --git a/public/openapi/write/posts/queue/notify.yaml b/public/openapi/write/posts/queue/notify.yaml new file mode 100644 index 0000000000..8569d9b232 --- /dev/null +++ b/public/openapi/write/posts/queue/notify.yaml @@ -0,0 +1,36 @@ +post: + summary: Notify the owner of a queued post + tags: + - QueuedPosts + parameters: + - in: path + name: id + schema: + type: string + required: true + description: a valid queued post id + example: 2 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: body of the notification message + responses: + '200': + description: post successfully accepted + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + '400': + description: Bad request, invalid post id \ No newline at end of file diff --git a/public/src/client/post-queue.js b/public/src/client/post-queue.js index ff5fa931d7..5cdf120517 100644 --- a/public/src/client/post-queue.js +++ b/public/src/client/post-queue.js @@ -69,14 +69,10 @@ define('forum/post-queue', [ const id = textarea.parents('[data-id]').attr('data-id'); const titleEdit = triggerClass === '[data-action="editTitle"]'; - socket.emit('posts.editQueuedContent', { - id: id, + api.put(`/posts/queue/${id}`, { title: titleEdit ? textarea.val() : undefined, content: titleEdit ? undefined : textarea.val(), - }, function (err, data) { - if (err) { - return alerts.error(err); - } + }).then((data) => { if (titleEdit) { preview.find('.title-text').text(data.postData.title); } else { @@ -85,7 +81,7 @@ define('forum/post-queue', [ textarea.parent().addClass('hidden'); preview.removeClass('hidden'); - }); + }).catch(alerts.error); }); } @@ -96,8 +92,7 @@ define('forum/post-queue', [ onSubmit: function (selectedCategory) { Promise.all([ api.get(`/categories/${selectedCategory.cid}`, {}), - socket.emit('posts.editQueuedContent', { - id: id, + api.put(`/posts/queue/${id}`, { cid: selectedCategory.cid, }), ]).then(function (result) { @@ -174,6 +169,35 @@ define('forum/post-queue', [ async function handleQueueActions() { // accept, reject, notify + + const parent = $(this).parents('[data-id]'); + const action = $(this).attr('data-action'); + const id = parent.attr('data-id'); + const listContainer = parent.get(0).parentNode; + + if ((!['accept', 'reject', 'notify'].includes(action)) || + (action === 'reject' && !await confirmReject(ajaxify.data.canAccept ? '[[post-queue:confirm-reject]]' : '[[post-queue:confirm-remove]]'))) { + return; + } + + doAction(action, id).then(function () { + if (action === 'accept' || action === 'reject') { + parent.remove(); + } + + if (listContainer.childElementCount === 0) { + if (ajaxify.data.singlePost) { + ajaxify.go('/post-queue' + window.location.search); + } else { + ajaxify.refresh(); + } + } + }).catch(alerts.error); + + return false; + } + + async function doAction(action, id) { function getMessage() { return new Promise((resolve) => { const modal = bootbox.dialog({ @@ -194,36 +218,16 @@ define('forum/post-queue', [ }); } - const parent = $(this).parents('[data-id]'); - const action = $(this).attr('data-action'); - const id = parent.attr('data-id'); - const listContainer = parent.get(0).parentNode; - - if ((!['accept', 'reject', 'notify'].includes(action)) || - (action === 'reject' && !await confirmReject(ajaxify.data.canAccept ? '[[post-queue:confirm-reject]]' : '[[post-queue:confirm-remove]]'))) { - return; + const actionsMap = { + accept: () => api.post(`/posts/queue/${id}`, {}), + reject: () => api.del(`/posts/queue/${id}`, {}), + notify: async () => api.post(`/posts/queue/${id}/notify`, { message: await getMessage() }), + }; + if (actionsMap[action]) { + const result = actionsMap[action](); + return (result instanceof Promise ? result : Promise.resolve(result)); } - - socket.emit('posts.' + action, { - id: id, - message: action === 'notify' ? await getMessage() : undefined, - }, function (err) { - if (err) { - return alerts.error(err); - } - if (action === 'accept' || action === 'reject') { - parent.remove(); - } - - if (listContainer.childElementCount === 0) { - if (ajaxify.data.singlePost) { - ajaxify.go('/post-queue' + window.location.search); - } else { - ajaxify.refresh(); - } - } - }); - return false; + throw new Error(`Unknown action: ${action}`); } function handleBulkActions() { @@ -244,7 +248,7 @@ define('forum/post-queue', [ return; } const action = bulkAction.split('-')[0]; - const promises = ids.map(id => socket.emit('posts.' + action, { id: id })); + const promises = ids.map(id => doAction(action, id)); Promise.allSettled(promises).then(function (results) { const fulfilled = results.filter(res => res.status === 'fulfilled').length; diff --git a/src/api/posts.js b/src/api/posts.js index a7111e0c22..b39c173eb6 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -17,6 +17,8 @@ const activitypub = require('../activitypub'); const apiHelpers = require('./helpers'); const websockets = require('../socket.io'); const socketHelpers = require('../socket.io/helpers'); +const translator = require('../translator'); +const notifications = require('../notifications'); const postsAPI = module.exports; @@ -574,3 +576,91 @@ postsAPI.getReplies = async (caller, { pid }) => { return postData; }; + +postsAPI.acceptQueuedPost = async (caller, data) => { + await canEditQueue(caller.uid, data, 'accept'); + const result = await posts.submitFromQueue(data.id); + if (result && caller.uid !== parseInt(result.uid, 10)) { + await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`); + } + await logQueueEvent(caller, result, 'accept'); + return { type: result.type, pid: result.pid, tid: result.tid }; +}; + +postsAPI.removeQueuedPost = async (caller, data) => { + await canEditQueue(caller.uid, data, 'reject'); + const result = await posts.removeFromQueue(data.id); + if (result && caller.uid !== parseInt(result.uid, 10)) { + await sendQueueNotification('post-queue-rejected', result.uid, '/'); + } + await logQueueEvent(caller, result, 'reject'); +}; + +postsAPI.editQueuedPost = async (caller, data) => { + if (!data || !data.id || (!data.content && !data.title && !data.cid)) { + throw new Error('[[error:invalid-data]]'); + } + await posts.editQueuedContent(caller.uid, data); + if (data.content) { + return await plugins.hooks.fire('filter:parse.post', { postData: data }); + } + return { postData: data }; +}; + +postsAPI.notifyQueuedPostOwner = async (caller, data) => { + await canEditQueue(caller.uid, data, 'notify'); + const result = await posts.getFromQueue(data.id); + if (result) { + await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message))); + } +}; + +async function canEditQueue(uid, data, action) { + const [canEditQueue, queuedPost] = await Promise.all([ + posts.canEditQueue(uid, data, action), + posts.getFromQueue(data.id), + ]); + if (!queuedPost) { + throw new Error('[[error:no-post]]'); + } + if (!canEditQueue) { + throw new Error('[[error:no-privileges]]'); + } +} + +async function logQueueEvent(caller, result, type) { + const eventData = { + type: `post-queue-${result.type}-${type}`, + uid: caller.uid, + ip: caller.ip, + content: result.data.content, + targetUid: result.uid, + }; + if (result.type === 'topic') { + eventData.cid = result.data.cid; + eventData.title = result.data.title; + } else { + eventData.tid = result.data.tid; + } + if (result.pid) { + eventData.pid = result.pid; + } + await events.log(eventData); +} + +async function sendQueueNotification(type, targetUid, path, notificationText) { + const bodyShort = notificationText ? + translator.compile(`notifications:${type}`, notificationText) : + translator.compile(`notifications:${type}`); + const notifData = { + type: type, + nid: `${type}-${targetUid}-${path}`, + bodyShort: bodyShort, + path: path, + }; + if (parseInt(meta.config.postQueueNotificationUid, 10) > 0) { + notifData.from = meta.config.postQueueNotificationUid; + } + const notifObj = await notifications.create(notifData); + await notifications.push(notifObj, [targetUid]); +} diff --git a/src/controllers/mods.js b/src/controllers/mods.js index c0abc18fe8..2726e600d4 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -237,6 +237,7 @@ modsController.postQueue = async function (req, res, next) { .map((post) => { const isSelf = post.user.uid === req.uid; post.canAccept = !isSelf && (isAdmin || isGlobalMod || !!moderatedCids.length); + post.canEdit = isSelf || isAdmin || isGlobalMod; return post; }); diff --git a/src/controllers/write/posts.js b/src/controllers/write/posts.js index 884517c126..5828b44704 100644 --- a/src/controllers/write/posts.js +++ b/src/controllers/write/posts.js @@ -189,3 +189,24 @@ Posts.getReplies = async (req, res) => { helpers.formatApiResponse(200, res, { replies }); }; + +Posts.acceptQueuedPost = async (req, res) => { + const post = await api.posts.acceptQueuedPost(req, { id: req.params.id }); + helpers.formatApiResponse(200, res, { post }); +}; + +Posts.removeQueuedPost = async (req, res) => { + await api.posts.removeQueuedPost(req, { id: req.params.id }); + helpers.formatApiResponse(200, res); +}; + +Posts.editQueuedPost = async (req, res) => { + const result = await api.posts.editQueuedPost(req, { id: req.params.id, ...req.body }); + helpers.formatApiResponse(200, res, result); +}; + +Posts.notifyQueuedPostOwner = async (req, res) => { + const { id } = req.params; + await api.posts.notifyQueuedPostOwner(req, { id, message: req.body.message }); + helpers.formatApiResponse(200, res); +}; \ No newline at end of file diff --git a/src/posts/queue.js b/src/posts/queue.js index 9f6b21636d..8c1bbf90d0 100644 --- a/src/posts/queue.js +++ b/src/posts/queue.js @@ -307,9 +307,11 @@ module.exports = function (Posts) { if (data.type === 'topic') { const result = await createTopic(data.data); data.pid = result.postData.pid; + data.tid = result.topicData.tid; } else if (data.type === 'reply') { const result = await createReply(data.data); data.pid = result.pid; + data.tid = result.tid; } await removeFromQueue(id); plugins.hooks.fire('action:post-queue:submitFromQueue', { data: data }); diff --git a/src/routes/write/posts.js b/src/routes/write/posts.js index 829dd56df9..2c9a54be64 100644 --- a/src/routes/write/posts.js +++ b/src/routes/write/posts.js @@ -41,6 +41,12 @@ module.exports = function () { setupApiRoute(router, 'get', '/:pid/replies', [middleware.assert.post], controllers.write.posts.getReplies); + setupApiRoute(router, 'post', '/queue/:id', controllers.write.posts.acceptQueuedPost); + setupApiRoute(router, 'delete', '/queue/:id', controllers.write.posts.removeQueuedPost); + setupApiRoute(router, 'put', '/queue/:id', controllers.write.posts.editQueuedPost); + setupApiRoute(router, 'post', '/queue/:id/notify', [middleware.checkRequired.bind(null, ['message'])], controllers.write.posts.notifyQueuedPostOwner); + + // Shorthand route to access post routes by topic index router.all('/+byIndex/:index*?', [middleware.checkRequired.bind(null, ['tid'])], controllers.write.posts.redirectByIndex); diff --git a/src/socket.io/posts.js b/src/socket.io/posts.js index d6833e363a..b20fbe9b97 100644 --- a/src/socket.io/posts.js +++ b/src/socket.io/posts.js @@ -1,18 +1,10 @@ 'use strict'; -const validator = require('validator'); - const db = require('../database'); const posts = require('../posts'); const privileges = require('../privileges'); -const plugins = require('../plugins'); -const meta = require('../meta'); const topics = require('../topics'); -const notifications = require('../notifications'); const utils = require('../utils'); -const events = require('../events'); -const translator = require('../translator'); - const api = require('../api'); const sockets = require('.'); @@ -99,90 +91,23 @@ SocketPosts.getReplies = async function (socket, pid) { }; SocketPosts.accept = async function (socket, data) { - await canEditQueue(socket, data, 'accept'); - const result = await posts.submitFromQueue(data.id); - if (result && socket.uid !== parseInt(result.uid, 10)) { - await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`); - } - await logQueueEvent(socket, result, 'accept'); + sockets.warnDeprecated(socket, 'POST /api/v3/posts/queue/:id'); + await api.posts.acceptQueuedPost(socket, data); }; SocketPosts.reject = async function (socket, data) { - await canEditQueue(socket, data, 'reject'); - const result = await posts.removeFromQueue(data.id); - if (result && socket.uid !== parseInt(result.uid, 10)) { - await sendQueueNotification('post-queue-rejected', result.uid, '/'); - } - await logQueueEvent(socket, result, 'reject'); + sockets.warnDeprecated(socket, 'DELETE /api/v3/posts/queue/:id'); + await api.posts.removeQueuedPost(socket, data); }; -async function logQueueEvent(socket, result, type) { - const eventData = { - type: `post-queue-${result.type}-${type}`, - uid: socket.uid, - ip: socket.ip, - content: result.data.content, - targetUid: result.uid, - }; - if (result.type === 'topic') { - eventData.cid = result.data.cid; - eventData.title = result.data.title; - } else { - eventData.tid = result.data.tid; - } - if (result.pid) { - eventData.pid = result.pid; - } - await events.log(eventData); -} - SocketPosts.notify = async function (socket, data) { - await canEditQueue(socket, data, 'notify'); - const result = await posts.getFromQueue(data.id); - if (result) { - await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message))); - } + sockets.warnDeprecated(socket, 'POST /api/v3/posts/queue/:id/notify'); + await api.posts.notifyQueuedPostOwner(socket, data); }; -async function canEditQueue(socket, data, action) { - const [canEditQueue, queuedPost] = await Promise.all([ - posts.canEditQueue(socket.uid, data, action), - posts.getFromQueue(data.id), - ]); - if (!queuedPost) { - throw new Error('[[error:no-post]]'); - } - if (!canEditQueue) { - throw new Error('[[error:no-privileges]]'); - } -} - -async function sendQueueNotification(type, targetUid, path, notificationText) { - const bodyShort = notificationText ? - translator.compile(`notifications:${type}`, notificationText) : - translator.compile(`notifications:${type}`); - const notifData = { - type: type, - nid: `${type}-${targetUid}-${path}`, - bodyShort: bodyShort, - path: path, - }; - if (parseInt(meta.config.postQueueNotificationUid, 10) > 0) { - notifData.from = meta.config.postQueueNotificationUid; - } - const notifObj = await notifications.create(notifData); - await notifications.push(notifObj, [targetUid]); -} - SocketPosts.editQueuedContent = async function (socket, data) { - if (!data || !data.id || (!data.content && !data.title && !data.cid)) { - throw new Error('[[error:invalid-data]]'); - } - await posts.editQueuedContent(socket.uid, data); - if (data.content) { - return await plugins.hooks.fire('filter:parse.post', { postData: data }); - } - return { postData: data }; + sockets.warnDeprecated(socket, 'PUT /api/v3/posts/queue/:id'); + return await api.posts.editQueuedPost(socket, data); }; require('../promisify')(SocketPosts); diff --git a/src/views/post-queue.tpl b/src/views/post-queue.tpl index 040fc7a9d5..113e8791a6 100644 --- a/src/views/post-queue.tpl +++ b/src/views/post-queue.tpl @@ -1,92 +1,158 @@ -{{{ if isAdmin }}} -{{{ if !enabled }}} -
[[post-queue:public-intro]]
-[[post-queue:public-description]]
-