From 7b793527f94a7e58345b44235dfe4d551cc2d386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20U=C5=9Fakl=C4=B1?= Date: Sun, 11 Jan 2026 14:38:14 -0500 Subject: [PATCH] Change owner rest route (#13881) * fix: dont use sass-embedded on freebsd, #13867 * fix: #13715, dont reduce hardcap if usersPerPage is < 50 * fix: closes #13872, use translator.compile for notification text so commas don't cause issues * fix: remove bidiControls from notification.bodyShort * refactor: move change owner call to rest api deprecate socket method * fix spec * test: one more fix * test: add 404 * test: fix tests :rage1: * test: update test to use new method --- public/openapi/write.yaml | 4 +++ public/openapi/write/posts/owner.yaml | 39 +++++++++++++++++++++++ public/openapi/write/posts/pid/owner.yaml | 39 +++++++++++++++++++++++ public/src/client/topic/change-owner.js | 11 +++---- public/src/utils.common.js | 4 ++- src/api/posts.js | 23 +++++++++++++ src/controllers/write/posts.js | 8 +++++ src/notifications.js | 3 ++ src/routes/write/posts.js | 2 ++ src/socket.io/helpers.js | 4 ++- src/socket.io/posts/tools.js | 22 +++---------- src/user/search.js | 3 +- src/utils.js | 6 ++-- test/posts.js | 2 +- test/utils.js | 20 ++++++++++++ 15 files changed, 159 insertions(+), 31 deletions(-) create mode 100644 public/openapi/write/posts/owner.yaml create mode 100644 public/openapi/write/posts/pid/owner.yaml diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index 7771469725..ef17b75737 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -206,6 +206,10 @@ paths: $ref: 'write/posts/queue/id.yaml' /posts/queue/{id}/notify: $ref: 'write/posts/queue/notify.yaml' + /posts/{pid}/owner: + $ref: 'write/posts/pid/owner.yaml' + /posts/owner: + $ref: 'write/posts/owner.yaml' /chats/: $ref: 'write/chats.yaml' /chats/unread: diff --git a/public/openapi/write/posts/owner.yaml b/public/openapi/write/posts/owner.yaml new file mode 100644 index 0000000000..b69e0b5b44 --- /dev/null +++ b/public/openapi/write/posts/owner.yaml @@ -0,0 +1,39 @@ +post: + tags: + - Posts + summary: Change owner of one or more posts + description: Change the owner of the posts identified by pids to the user uid. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - pids + - uid + properties: + pids: + type: array + items: + type: integer + description: Array of post IDs to change owner for + example: [2] + uid: + type: integer + description: Target user id + example: 1 + responses: + '200': + description: Owner changed successfully + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../components/schemas/Status.yaml#/Status + response: + type: object + '404': + description: Post not found diff --git a/public/openapi/write/posts/pid/owner.yaml b/public/openapi/write/posts/pid/owner.yaml new file mode 100644 index 0000000000..e49a627963 --- /dev/null +++ b/public/openapi/write/posts/pid/owner.yaml @@ -0,0 +1,39 @@ +put: + summary: Change owner of a post + description: Change the owner (uid) of a post identified by pid. + tags: + - Posts + parameters: + - name: pid + in: path + description: Post id + required: true + schema: + type: integer + example: 2 + requestBody: + description: New owner payload + required: true + content: + application/json: + schema: + type: object + required: + - uid + properties: + uid: + type: integer + description: User id of the new owner + example: 2 + responses: + '200': + description: Owner changed successfully + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object diff --git a/public/src/client/topic/change-owner.js b/public/src/client/topic/change-owner.js index b0b4e5be6c..9be031b62a 100644 --- a/public/src/client/topic/change-owner.js +++ b/public/src/client/topic/change-owner.js @@ -5,7 +5,8 @@ define('forum/topic/change-owner', [ 'postSelect', 'autocomplete', 'alerts', -], function (postSelect, autocomplete, alerts) { + 'api', +], function (postSelect, autocomplete, alerts, api) { const ChangeOwner = {}; let modal; @@ -69,14 +70,12 @@ define('forum/topic/change-owner', [ if (!toUid) { return; } - socket.emit('posts.changeOwner', { pids: postSelect.pids, toUid: toUid }, function (err) { - if (err) { - return alerts.error(err); - } + + api.post('/posts/owner', { pids: postSelect.pids, uid: toUid}).then(() => { ajaxify.go(`/post/${postSelect.pids[0]}`); closeModal(); - }); + }).catch(alerts.error); } function closeModal() { diff --git a/public/src/utils.common.js b/public/src/utils.common.js index 4ecf17e4f2..873292d22e 100644 --- a/public/src/utils.common.js +++ b/public/src/utils.common.js @@ -300,7 +300,9 @@ const utils = { const pattern = (tags || ['']).join('|'); return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), ''); }, - + stripBidiControls: function (input) { + return input.replace(/[\u202A-\u202E\u2066-\u2069]/g, ''); + }, cleanUpTag: function (tag, maxLength) { if (typeof tag !== 'string' || !tag.length) { return ''; diff --git a/src/api/posts.js b/src/api/posts.js index a7eecaa633..c9a570dec4 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -665,3 +665,26 @@ async function sendQueueNotification(type, targetUid, path, notificationText) { const notifObj = await notifications.create(notifData); await notifications.push(notifObj, [targetUid]); } + +postsAPI.changeOwner = async function (caller, data) { + if (!data || !Array.isArray(data.pids) || !data.uid) { + throw new Error('[[error:invalid-data]]'); + } + const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(caller.uid); + if (!isAdminOrGlobalMod) { + throw new Error('[[error:no-privileges]]'); + } + + const postData = await posts.changeOwner(data.pids, data.uid); + const logs = postData.map(({ pid, uid, cid }) => (events.log({ + type: 'post-change-owner', + uid: caller.uid, + ip: caller.ip, + targetUid: data.uid, + pid: pid, + originalUid: uid, + cid: cid, + }))); + + await Promise.all(logs); +}; \ No newline at end of file diff --git a/src/controllers/write/posts.js b/src/controllers/write/posts.js index 5828b44704..9e8053d17d 100644 --- a/src/controllers/write/posts.js +++ b/src/controllers/write/posts.js @@ -209,4 +209,12 @@ Posts.notifyQueuedPostOwner = async (req, res) => { const { id } = req.params; await api.posts.notifyQueuedPostOwner(req, { id, message: req.body.message }); helpers.formatApiResponse(200, res); +}; + +Posts.changeOwner = async (req, res) => { + await api.posts.changeOwner(req, { + pids: req.body.pids || (req.params.pid ? [req.params.pid] : []), + uid: req.body.uid, + }); + helpers.formatApiResponse(200, res); }; \ No newline at end of file diff --git a/src/notifications.js b/src/notifications.js index e71366417e..e7db55cf99 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -177,6 +177,9 @@ Notifications.create = async function (data) { if (!result.data) { return null; } + if (data.bodyShort) { + data.bodyShort = utils.stripBidiControls(data.bodyShort); + } await Promise.all([ db.sortedSetAdd('notifications', now, data.nid), db.setObject(`notifications:${data.nid}`, data), diff --git a/src/routes/write/posts.js b/src/routes/write/posts.js index 2c9a54be64..ed2c372461 100644 --- a/src/routes/write/posts.js +++ b/src/routes/write/posts.js @@ -46,6 +46,8 @@ module.exports = function () { setupApiRoute(router, 'put', '/queue/:id', controllers.write.posts.editQueuedPost); setupApiRoute(router, 'post', '/queue/:id/notify', [middleware.checkRequired.bind(null, ['message'])], controllers.write.posts.notifyQueuedPostOwner); + setupApiRoute(router, 'put', '/:pid/owner', [middleware.ensureLoggedIn, middleware.assert.post, middleware.checkRequired.bind(null, ['uid'])], controllers.write.posts.changeOwner); + setupApiRoute(router, 'post', '/owner', [middleware.ensureLoggedIn, middleware.checkRequired.bind(null, ['pids', 'uid'])], controllers.write.posts.changeOwner); // 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/helpers.js b/src/socket.io/helpers.js index 5def5138d4..194602f1b3 100644 --- a/src/socket.io/helpers.js +++ b/src/socket.io/helpers.js @@ -13,6 +13,7 @@ const notifications = require('../notifications'); const plugins = require('../plugins'); const utils = require('../utils'); const batch = require('../batch'); +const translator = require('../translator'); const SocketHelpers = module.exports; @@ -113,10 +114,11 @@ SocketHelpers.sendNotificationToPostOwner = async function (pid, fromuid, comman const title = utils.decodeHTMLEntities(topicTitle); const titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); + const bodyShort = translator.compile(notification, userData.displayname || userData.name, titleEscaped); const notifObj = await notifications.create({ type: command, - bodyShort: `[[${notification}, ${userData.displayname || userData.name}, ${titleEscaped}]]`, + bodyShort: bodyShort, bodyLong: postObj.content, pid: pid, tid: postData.tid, diff --git a/src/socket.io/posts/tools.js b/src/socket.io/posts/tools.js index 397b6ef2a4..99a09580bd 100644 --- a/src/socket.io/posts/tools.js +++ b/src/socket.io/posts/tools.js @@ -5,12 +5,13 @@ const nconf = require('nconf'); const db = require('../../database'); const posts = require('../../posts'); const flags = require('../../flags'); -const events = require('../../events'); const privileges = require('../../privileges'); const plugins = require('../../plugins'); const social = require('../../social'); const user = require('../../user'); const utils = require('../../utils'); +const sockets = require('../index'); +const api = require('../../api'); module.exports = function (SocketPosts) { SocketPosts.loadPostTools = async function (socket, data) { @@ -77,23 +78,8 @@ module.exports = function (SocketPosts) { if (!data || !Array.isArray(data.pids) || !data.toUid) { throw new Error('[[error:invalid-data]]'); } - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(socket.uid); - if (!isAdminOrGlobalMod) { - throw new Error('[[error:no-privileges]]'); - } - - const postData = await posts.changeOwner(data.pids, data.toUid); - const logs = postData.map(({ pid, uid, cid }) => (events.log({ - type: 'post-change-owner', - uid: socket.uid, - ip: socket.ip, - targetUid: data.toUid, - pid: pid, - originalUid: uid, - cid: cid, - }))); - - await Promise.all(logs); + sockets.warnDeprecated(socket, 'PUT /api/v3/posts/owner'); + await api.posts.changeOwner(socket, { pids: data.pids, uid: data.toUid }); }; SocketPosts.getEditors = async function (socket, data) { diff --git a/src/user/search.js b/src/user/search.js index 17df4b68f1..7edfeb73ca 100644 --- a/src/user/search.js +++ b/src/user/search.js @@ -119,8 +119,7 @@ module.exports = function (User) { const min = query; const max = query.substr(0, query.length - 1) + String.fromCharCode(query.charCodeAt(query.length - 1) + 1); - const resultsPerPage = meta.config.userSearchResultsPerPage; - hardCap = hardCap || resultsPerPage * 10; + hardCap = hardCap || 500; const data = await db.getSortedSetRangeByLex(`${searchBy}:sorted`, min, max, 0, hardCap); // const uids = data.map(data => data.split(':').pop()); diff --git a/src/utils.js b/src/utils.js index 2b620b91e5..55d3b5e793 100644 --- a/src/utils.js +++ b/src/utils.js @@ -42,9 +42,11 @@ utils.secureRandom = function (low, high) { }; utils.getSass = function () { + if (process.platform === 'freebsd') { + return require('sass'); + } try { - const sass = require('sass-embedded'); - return sass; + return require('sass-embedded'); } catch (err) { console.error(err.message); return require('sass'); diff --git a/test/posts.js b/test/posts.js index 2ba85734d4..84befcbfa5 100644 --- a/test/posts.js +++ b/test/posts.js @@ -118,7 +118,7 @@ describe('Post\'s', () => { it('should fail to change owner if user is not authorized', async () => { try { - await socketPosts.changeOwner({ uid: voterUid }, { pids: [1, 2], toUid: voterUid }); + await apiPosts.changeOwner({ uid: voterUid }, { pids: [1, 2], uid: voterUid }); } catch (err) { assert.strictEqual(err.message, '[[error:no-privileges]]'); } diff --git a/test/utils.js b/test/utils.js index e9ccbd4108..2e0ce72e8a 100644 --- a/test/utils.js +++ b/test/utils.js @@ -44,6 +44,26 @@ describe('Utility Methods', () => { done(); }); + describe('utils.stripBidiControls', () => { + it('should remove common bidi embedding and override controls', () => { + const input = '\u202AHello\u202C \u202BWorld\u202C \u202DDwellers\u202E'; + const out = utils.stripBidiControls(input); + assert.strictEqual(out, 'Hello World Dwellers'); + }); + + it('should remove bidirectional isolate formatting characters', () => { + const input = '\u2066abc\u2067def\u2068ghi\u2069'; + const out = utils.stripBidiControls(input); + assert.strictEqual(out, 'abcdefghi'); + }); + + it('should leave normal text unchanged', () => { + const input = 'plain text 123'; + const out = utils.stripBidiControls(input); + assert.strictEqual(out, 'plain text 123'); + }); + }); + it('should preserve case if requested', (done) => { assert.strictEqual(slugify('UPPER CASE', true), 'UPPER-CASE'); done();