diff --git a/public/openapi/components/schemas/Chats.yaml b/public/openapi/components/schemas/Chats.yaml index 85de6dd23c..545b2f9ff6 100644 --- a/public/openapi/components/schemas/Chats.yaml +++ b/public/openapi/components/schemas/Chats.yaml @@ -94,6 +94,44 @@ MessageObject: type: boolean cleanedContent: type: string +RoomUserList: + type: object + properties: + users: + type: array + items: + type: object + properties: + uid: + type: number + description: A user identifier + username: + type: string + description: A friendly name for a given user account + picture: + nullable: true + type: string + status: + type: string + displayname: + type: string + description: This is either username or fullname depending on forum and user settings + 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 + 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: "#f44336" + isOwner: + type: boolean + canKick: + type: boolean RoomObjectFull: # Messaging.loadRoom allOf: diff --git a/public/openapi/write/chats/roomId/users.yaml b/public/openapi/write/chats/roomId/users.yaml index ecf8a7cf89..2845338c57 100644 --- a/public/openapi/write/chats/roomId/users.yaml +++ b/public/openapi/write/chats/roomId/users.yaml @@ -22,40 +22,43 @@ get: status: $ref: ../../../components/schemas/Status.yaml#/Status response: - type: object - properties: - users: - type: array - items: - type: object - properties: - uid: - type: number - description: A user identifier - username: - type: string - description: A friendly name for a given user account - picture: - nullable: true - type: string - status: - type: string - displayname: - type: string - description: This is either username or fullname depending on forum and user settings - 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 - 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: "#f44336" - isOwner: - type: boolean - canKick: - type: boolean \ No newline at end of file + $ref: ../../../components/schemas/Chats.yaml#/RoomUserList +post: + tags: + - chats + summary: add users to chat room + description: This operation invites users to a chat room + parameters: + - in: path + name: roomId + schema: + type: number + required: true + description: a valid chat room id + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + uids: + type: array + description: A list of valid uids + example: [2] + items: + type: number + description: A valid uid + responses: + '200': + description: users successfully invited to chat room + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + $ref: ../../../components/schemas/Chats.yaml#/RoomUserList \ No newline at end of file diff --git a/public/src/client/chats.js b/public/src/client/chats.js index a98e368fa7..5ecee2bfdb 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -244,18 +244,15 @@ define('forum/chats', [ require(['autocomplete', 'translator'], function (autocomplete, translator) { autocomplete.user(searchInput, function (event, selected) { errorEl.text(''); - socket.emit('modules.chats.addUserToRoom', { - roomId: roomId, - username: selected.item.user.name, - }, function (err) { - if (err) { - translator.translate(err.message, function (translated) { - errorEl.text(translated); - }); - } - - Chats.refreshParticipantsList(roomId, modal); + api.post(`/chats/${roomId}/users`, { + uids: [selected.item.user.uid], + }).then((body) => { + Chats.refreshParticipantsList(roomId, modal, body); searchInput.val(''); + }).catch((err) => { + translator.translate(err.message, function (translated) { + errorEl.text(translated); + }); }); }); }); @@ -307,16 +304,21 @@ define('forum/chats', [ }); }; - Chats.refreshParticipantsList = function (roomId, modal) { + Chats.refreshParticipantsList = async (roomId, modal, data) => { const listEl = modal.find('.list-group'); - api.get(`/chats/${roomId}/users`, {}).then(({ users }) => { - app.parseAndTranslate('partials/modals/manage_room_users', { users }, function (html) { - listEl.html(html); - }); - }).catch(() => { - translator.translate('[[error:invalid-data]]', function (translated) { - listEl.find('li').text(translated); - }); + + if (!data) { + try { + data = await api.get(`/chats/${roomId}/users`, {}); + } catch (err) { + translator.translate('[[error:invalid-data]]', function (translated) { + listEl.find('li').text(translated); + }); + } + } + + app.parseAndTranslate('partials/modals/manage_room_users', data, function (html) { + listEl.html(html); }); }; diff --git a/src/api/chats.js b/src/api/chats.js index cabe54480e..81c79e2841 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -83,3 +83,21 @@ chatsAPI.users = async (caller, data) => { }); return { users }; }; + +chatsAPI.invite = async (caller, data) => { + const userCount = await messaging.getUserCountInRoom(data.roomId); + const maxUsers = meta.config.maximumUsersInChatRoom; + if (maxUsers && userCount >= maxUsers) { + throw new Error('[[error:cant-add-more-users-to-chat-room]]'); + } + + const uidsExist = await user.exists(data.uids); + if (!uidsExist.every(Boolean)) { + throw new Error('[[error:no-user]]'); + } + await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid))); + await messaging.addUsersToRoom(caller.uid, data.uids, data.roomId); + + delete data.uids; + return chatsAPI.users(caller, data); +}; diff --git a/src/controllers/write/chats.js b/src/controllers/write/chats.js index 752d950db4..0f2b554ac7 100644 --- a/src/controllers/write/chats.js +++ b/src/controllers/write/chats.js @@ -61,7 +61,12 @@ Chats.users = async (req, res) => { }; Chats.invite = async (req, res) => { - // ... + const users = await api.chats.invite(req, { + ...req.body, + roomId: req.params.roomId, + }); + + helpers.formatApiResponse(200, res, users); }; Chats.kick = async (req, res) => { diff --git a/src/routes/write/chats.js b/src/routes/write/chats.js index 65b65813b3..b1b62417d5 100644 --- a/src/routes/write/chats.js +++ b/src/routes/write/chats.js @@ -20,7 +20,7 @@ module.exports = function () { // no route for room deletion, noted here just in case... setupApiRoute(router, 'get', '/:roomId/users', [...middlewares, middleware.assert.room], controllers.write.chats.users); - // setupApiRoute(router, 'put', '/:roomId/users', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.invite); + setupApiRoute(router, 'post', '/:roomId/users', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.invite); // setupApiRoute(router, 'delete', '/:roomId/users', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.kick); setupApiRoute(router, 'get', '/:roomId/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.get); diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 11fb2c7913..a199069ea4 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -84,7 +84,7 @@ SocketModules.chats.loadRoom = async function (socket, data) { }; SocketModules.chats.getUsersInRoom = async function (socket, data) { - sockets.warnDeprecated(socket, 'GET /api/v3/chats/:roomId/user'); + sockets.warnDeprecated(socket, 'GET /api/v3/chats/:roomId/users'); if (!data || !data.roomId) { throw new Error('[[error:invalid-data]]'); @@ -98,6 +98,8 @@ SocketModules.chats.getUsersInRoom = async function (socket, data) { }; SocketModules.chats.addUserToRoom = async function (socket, data) { + sockets.warnDeprecated(socket, 'POST /api/v3/chats/:roomId/users'); + if (!data || !data.roomId || !data.username) { throw new Error('[[error:invalid-data]]'); } @@ -107,18 +109,11 @@ SocketModules.chats.addUserToRoom = async function (socket, data) { throw new Error('[[error:no-privileges]]'); } - const userCount = await Messaging.getUserCountInRoom(data.roomId); - const maxUsers = meta.config.maximumUsersInChatRoom; - if (maxUsers && userCount >= maxUsers) { - throw new Error('[[error:cant-add-more-users-to-chat-room]]'); - } + // Revised API now takes uids, not usernames + data.uids = [await user.getUidByUsername(data.username)]; + delete data.username; - const uid = await user.getUidByUsername(data.username); - if (!uid) { - throw new Error('[[error:no-user]]'); - } - await Messaging.canMessageUser(socket.uid, uid); - await Messaging.addUsersToRoom(socket.uid, [uid], data.roomId); + await api.chats.invite(socket, data); }; SocketModules.chats.removeUserFromRoom = async function (socket, data) { diff --git a/test/messaging.js b/test/messaging.js index 9cd1a55643..cbafacb5a9 100644 --- a/test/messaging.js +++ b/test/messaging.js @@ -88,17 +88,13 @@ describe('Messaging Library', () => { }); }); - it('should NOT allow messages to be sent to a restricted user', (done) => { - User.setSetting(mocks.users.baz.uid, 'restrictChat', '1', (err) => { - assert.ifError(err); - Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid, (err) => { - assert.strictEqual(err.message, '[[error:chat-restricted]]'); - socketModules.chats.addUserToRoom({ uid: mocks.users.herp.uid }, { roomId: 1, username: 'baz' }, (err) => { - assert.equal(err.message, '[[error:chat-restricted]]'); - done(); - }); - }); - }); + it('should NOT allow messages to be sent to a restricted user', async () => { + await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); + try { + await Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid); + } catch (err) { + assert.strictEqual(err.message, '[[error:chat-restricted]]'); + } }); it('should always allow admins through', (done) => { @@ -169,35 +165,26 @@ describe('Messaging Library', () => { assert.equal(body2.status.message, await translator.translate('[[error:cant-edit-chat-message]]')); }); - it('should fail to add user to room with invalid data', (done) => { - socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, { roomId: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, { roomId: roomId, username: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); + it('should fail to add user to room with invalid data', async () => { + let { statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, {}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); + + ({ statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, { uids: [null] }, 'foo')); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); }); - it('should add a user to room', (done) => { - socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, { roomId: roomId, username: 'herp' }, (err) => { - assert.ifError(err); - Messaging.isUserInRoom(mocks.users.herp.uid, roomId, (err, isInRoom) => { - assert.ifError(err); - assert(isInRoom); - done(); - }); - }); + it('should add a user to room', async () => { + await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.herp.uid] }, 'foo'); + const isInRoom = await Messaging.isUserInRoom(mocks.users.herp.uid, roomId); + assert(isInRoom); }); it('should get users in room', async () => { const { body } = await callv3API('get', `/chats/${roomId}/users`, {}, 'foo'); assert(Array.isArray(body.response.users)); assert.strictEqual(body.response.users.length, 3); - console.log(body.response.users); }); it('should throw error if user is not in room', async () => { @@ -206,27 +193,24 @@ describe('Messaging Library', () => { assert.equal(body.status.message, await translator.translate('[[error:no-privileges]]')); }); - it('should fail to add users to room if max is reached', (done) => { + it('should fail to add users to room if max is reached', async () => { meta.config.maximumUsersInChatRoom = 2; - socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, { roomId: roomId, username: 'test' }, (err) => { - assert.equal(err.message, '[[error:cant-add-more-users-to-chat-room]]'); - meta.config.maximumUsersInChatRoom = 0; - done(); - }); + const { statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.bar.uid] }, 'foo'); + assert.strictEqual(statusCode, 400); + assert.equal(body.status.message, await translator.translate('[[error:cant-add-more-users-to-chat-room]]')); + meta.config.maximumUsersInChatRoom = 0; }); - it('should fail to add users to room if user does not exist', (done) => { - socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, { roomId: roomId, username: 'doesnotexist' }, (err) => { - assert.equal(err.message, '[[error:no-user]]'); - done(); - }); + it('should fail to add users to room if user does not exist', async () => { + const { statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, { uids: [98237498234] }, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); }); - it('should fail to add self to room', (done) => { - socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, { roomId: roomId, username: 'foo' }, (err) => { - assert.equal(err.message, '[[error:cant-chat-with-yourself]]'); - done(); - }); + it('should fail to add self to room', async () => { + const { statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.foo.uid] }, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:cant-chat-with-yourself]]')); }); it('should fail to leave room with invalid data', (done) => { @@ -286,7 +270,7 @@ describe('Messaging Library', () => { uids: [mocks.users.foo.uid], }, 'herp'); - await util.promisify(socketModules.chats.addUserToRoom)({ uid: mocks.users.herp.uid }, { roomId: body.response.roomId, username: 'baz' }); + await callv3API('post', `/chats/${body.response.roomId}/users`, { uids: [mocks.users.baz.uid] }, 'herp'); await util.promisify(socketModules.chats.leave)({ uid: mocks.users.herp.uid }, body.response.roomId); const data = await Messaging.getRoomData(body.response.roomId); @@ -348,9 +332,7 @@ describe('Messaging Library', () => { assert.equal(err.message, '[[error:cant-remove-last-user]]'); } - await util.promisify( - socketModules.chats.addUserToRoom - )({ uid: mocks.users.foo.uid }, { roomId: roomId, username: 'baz' }); + await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.baz.uid] }, 'foo'); await util.promisify( socketModules.chats.removeUserFromRoom )({ uid: mocks.users.foo.uid }, { roomId: roomId, uid: mocks.users.herp.uid }); @@ -437,7 +419,7 @@ describe('Messaging Library', () => { const { roomId } = body.response; assert(roomId); - await socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, { roomId: roomId, username: 'herp' }); + await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.herp.uid] }, 'foo'); await db.sortedSetAdd('users:online', Date.now() - ((meta.config.onlineCutoff * 60000) + 50000), mocks.users.herp.uid); await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'second chat message **bold** text' }, 'foo'); @@ -626,7 +608,7 @@ describe('Messaging Library', () => { let mid; let mid2; before(async () => { - await socketModules.chats.addUserToRoom({ uid: mocks.users.foo.uid }, { roomId: roomId, username: 'baz' }); + await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.baz.uid] }, 'foo'); let { body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'first chat message' }, 'foo'); mid = body.response.mid; ({ body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'second chat message' }, 'baz'));