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
This commit is contained in:
Barış Uşaklı
2026-01-11 14:38:14 -05:00
committed by GitHub
parent 74e478200f
commit 7b793527f9
15 changed files with 159 additions and 31 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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() {

View File

@@ -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 '';

View File

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -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, '&#37;').replace(/,/g, '&#44;');
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,

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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]]');
}

View File

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