From c84a069dfe43fa54016cec3fa06303ca3b49e638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 21 Jan 2020 19:25:42 +0100 Subject: [PATCH 01/12] Use an API route for sending chat messages. --- src/controllers/chat.js | 21 +++++++++++++++++++++ src/plugins/chat.js | 9 +++++++-- src/routes/chat.js | 7 +++++++ src/validations.js | 10 ++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/controllers/chat.js b/src/controllers/chat.js index 8ceb8207..ea45675e 100644 --- a/src/controllers/chat.js +++ b/src/controllers/chat.js @@ -60,6 +60,26 @@ async function unmuteUser(req) { return toItemResponse({}); } +/** + * @typedef {object} SendMessageBody + * @prop {string} message + */ + +/** + * @type {import('../types').AuthenticatedController<{}, {}, SendMessageBody>} + */ +async function sendMessage(req) { + const { user } = req; + const { message } = req.body; + const { chat } = req.uwave; + + const result = await chat.send(user, message); + return toItemResponse({ + _id: result ? result.id : null, + message, + }); +} + /** * @type {import('../types').AuthenticatedController} */ @@ -110,6 +130,7 @@ async function deleteMessage(req) { exports.muteUser = muteUser; exports.unmuteUser = unmuteUser; +exports.sendMessage = sendMessage; exports.deleteAll = deleteAll; exports.deleteByUser = deleteByUser; exports.deleteMessage = deleteMessage; diff --git a/src/plugins/chat.js b/src/plugins/chat.js index 2489843d..cd880dbf 100644 --- a/src/plugins/chat.js +++ b/src/plugins/chat.js @@ -92,17 +92,22 @@ class Chat { */ async send(user, message) { if (await this.isMuted(user)) { - return; + return null; } this.#chatID += 1; + const id = `${user.id}-${this.chatID}`; this.#uw.publish('chat:message', { - id: `${user.id}-${this.#chatID}`, + id, userID: user.id, message: this.truncate(message), timestamp: Date.now(), }); + + return { + id, + }; } /** diff --git a/src/routes/chat.js b/src/routes/chat.js index c214e050..d59f66cb 100644 --- a/src/routes/chat.js +++ b/src/routes/chat.js @@ -9,6 +9,13 @@ const controller = require('../controllers/chat'); function chatRoutes() { return Router() + // POST / - Send a chat message. + .post( + '/', + protect('chat.send'), + schema(validations.sendChatMessage), + route(controller.sendMessage), + ) // DELETE /chat/ - Clear the chat (delete all messages). .delete( '/', diff --git a/src/validations.js b/src/validations.js index 2c9d7d45..c37a226b 100644 --- a/src/validations.js +++ b/src/validations.js @@ -214,6 +214,16 @@ exports.getRoomHistory = /** @type {const} */ ({ // Validations for chat routes: +exports.sendChatMessage = /** @type {const} */ ({ + body: { + type: 'object', + properties: { + message: { type: 'string', minLength: 1 }, + }, + required: ['message'], + }, +}); + exports.deleteChatByUser = /** @type {const} */ ({ params: { type: 'object', From d9af9880fd85b8385fa3f9744c296154bc3f5690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 08:30:08 +0200 Subject: [PATCH 02/12] make test chat timeout more flexible --- test/chat.mjs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/test/chat.mjs b/test/chat.mjs index 02af8e14..884db62b 100644 --- a/test/chat.mjs +++ b/test/chat.mjs @@ -5,8 +5,23 @@ import createUwave from './utils/createUwave.mjs'; const sandbox = sinon.createSandbox(); +/** + * @param {() => boolean} predicate + * @param {number} timeout + */ +async function waitFor(predicate, timeout) { + const end = Date.now() + timeout; + while (Date.now() < end) { + await delay(10); // eslint-disable-line no-await-in-loop + if (predicate()) { + return; + } + } + throw new Error('Timed out waiting for predicate'); +} + // Can't get this to be reliable, skip for now -describe.skip('Chat', () => { +describe('Chat', () => { let uw; beforeEach(async () => { @@ -28,9 +43,13 @@ describe.skip('Chat', () => { }); ws.send(JSON.stringify({ command: 'sendChat', data: 'Message text' })); - await delay(500); - - assert(receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === user.id && message.data.message === 'Message text')); + await waitFor(() => ( + receivedMessages.some((message) => ( + message.command === 'chatMessage' + && message.data.userID === user.id + && message.data.message === 'Message text' + )) + ), 5_000); }); it('does not broadcast chat messages from muted users', async () => { @@ -52,7 +71,7 @@ describe.skip('Chat', () => { ws.send(JSON.stringify({ command: 'sendChat', data: 'unmuted' })); mutedWs.send(JSON.stringify({ command: 'sendChat', data: 'muted' })); - await delay(1500); + await waitFor(() => receivedMessages.length >= 2, 5_000); assert(receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === user.id)); assert(!receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === mutedUser.id)); From 4fa3e51a6ac52d656160452c2a65c013dd1b4858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 08:39:18 +0200 Subject: [PATCH 03/12] tests for POST /chat/ route --- src/routes/chat.js | 2 +- test/chat.mjs | 67 ++++++++++++++++++++++++++++++++++++++++++- test/utils/plugin.mjs | 8 +++--- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/routes/chat.js b/src/routes/chat.js index d59f66cb..2f594f84 100644 --- a/src/routes/chat.js +++ b/src/routes/chat.js @@ -9,7 +9,7 @@ const controller = require('../controllers/chat'); function chatRoutes() { return Router() - // POST / - Send a chat message. + // POST /chat/ - Send a chat message. .post( '/', protect('chat.send'), diff --git a/test/chat.mjs b/test/chat.mjs index 884db62b..19dc27ae 100644 --- a/test/chat.mjs +++ b/test/chat.mjs @@ -1,6 +1,7 @@ import assert from 'assert'; import sinon from 'sinon'; import delay from 'delay'; +import supertest from 'supertest'; import createUwave from './utils/createUwave.mjs'; const sandbox = sinon.createSandbox(); @@ -32,7 +33,7 @@ describe('Chat', () => { await uw.destroy(); }); - it('can broadcast chat messages', async () => { + it('can send chat messages through WebSockets', async () => { const user = await uw.test.createUser(); const ws = await uw.test.connectToWebSocketAs(user); @@ -76,4 +77,68 @@ describe('Chat', () => { assert(receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === user.id)); assert(!receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === mutedUser.id)); }); + + describe('POST /chat', () => { + it('requires authentication', async () => { + await supertest(uw.server) + .post('/api/chat') + .send({ message: 'blah' }) + .expect(401); + }); + + it('validates input', async () => { + const user = await uw.test.createUser(); + const token = await uw.test.createTestSessionToken(user); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ not: 'a message' }) + .expect(400); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send('text') + .expect(400); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: null }) + .expect(400); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: '' }) + .expect(400); + }); + + it('broadcasts a chat message', async () => { + const user = await uw.test.createUser(); + const token = await uw.test.createTestSessionToken(user); + const ws = await uw.test.connectToWebSocketAs(user); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: 'HTTP message text' }) + .expect(200); + + const receivedMessages = []; + ws.on('message', (data) => { + receivedMessages.push(JSON.parse(data)); + }); + + ws.send(JSON.stringify({ command: 'sendChat', data: 'HTTP message text' })); + await waitFor(() => ( + receivedMessages.some((message) => ( + message.command === 'chatMessage' + && message.data.userID === user.id + && message.data.message === 'HTTP message text' + )) + ), 5_000); + }); + }); }); diff --git a/test/utils/plugin.mjs b/test/utils/plugin.mjs index cc4aa890..9105f4ba 100644 --- a/test/utils/plugin.mjs +++ b/test/utils/plugin.mjs @@ -8,13 +8,13 @@ async function testPlugin(uw) { let i = Date.now(); async function createUser() { - const props = { + i += 1; + const user = new User({ _id: new mongoose.Types.ObjectId(), username: `test_user_${i.toString(36)}`, slug: i.toString(36), - }; - i += 1; - const user = new User(props); + roles: ['user'], + }); await user.save(); return user; } From 8aca0056a684e3aa998537a7e3c7a59f935bd7aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 08:46:12 +0200 Subject: [PATCH 04/12] also respond with the possibly truncated message --- src/controllers/chat.js | 9 +++++---- src/errors/index.js | 7 +++++++ src/plugins/chat.js | 6 ++++-- test/chat.mjs | 6 +++++- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/controllers/chat.js b/src/controllers/chat.js index ea45675e..27c832f4 100644 --- a/src/controllers/chat.js +++ b/src/controllers/chat.js @@ -3,6 +3,7 @@ const { UserNotFoundError, CannotSelfMuteError, + ChatMutedError, } = require('../errors'); const toItemResponse = require('../utils/toItemResponse'); @@ -74,10 +75,10 @@ async function sendMessage(req) { const { chat } = req.uwave; const result = await chat.send(user, message); - return toItemResponse({ - _id: result ? result.id : null, - message, - }); + if (!result) { + throw new ChatMutedError(); + } + return toItemResponse(result); } /** diff --git a/src/errors/index.js b/src/errors/index.js index de5b0e99..beccf9ba 100644 --- a/src/errors/index.js +++ b/src/errors/index.js @@ -207,6 +207,12 @@ const CannotSelfMuteError = createErrorClass('CannotSelfMuteError', { base: Forbidden, }); +const ChatMutedError = createErrorClass('ChatMutedError', { + code: 'chat-muted', + string: 'errors.chatMuted', + base: Forbidden, +}); + const SourceNotFoundError = createErrorClass('SourceNotFoundError', { code: 'source-not-found', string: 'errors.sourceNotFound', @@ -270,6 +276,7 @@ exports.MediaNotFoundError = MediaNotFoundError; exports.ItemNotInPlaylistError = ItemNotInPlaylistError; exports.CannotSelfFavoriteError = CannotSelfFavoriteError; exports.CannotSelfMuteError = CannotSelfMuteError; +exports.ChatMutedError = ChatMutedError; exports.SourceNotFoundError = SourceNotFoundError; exports.SourceNoImportError = SourceNoImportError; exports.EmptyPlaylistError = EmptyPlaylistError; diff --git a/src/plugins/chat.js b/src/plugins/chat.js index 96aa4598..5d0936a6 100644 --- a/src/plugins/chat.js +++ b/src/plugins/chat.js @@ -95,15 +95,17 @@ class Chat { } const id = randomUUID(); + const truncatedMessage = this.truncate(message); this.#uw.publish('chat:message', { id, userID: user.id, - message: this.truncate(message), + message: truncatedMessage, timestamp: Date.now(), }); return { - id, + _id: id, + message: truncatedMessage, }; } diff --git a/test/chat.mjs b/test/chat.mjs index 19dc27ae..20fdb194 100644 --- a/test/chat.mjs +++ b/test/chat.mjs @@ -120,11 +120,15 @@ describe('Chat', () => { const token = await uw.test.createTestSessionToken(user); const ws = await uw.test.connectToWebSocketAs(user); - await supertest(uw.server) + const res = await supertest(uw.server) .post('/api/chat') .set('Cookie', `uwsession=${token}`) .send({ message: 'HTTP message text' }) .expect(200); + sinon.assert.match(res.body.data, { + _id: sinon.match.string, + message: sinon.match.string, + }); const receivedMessages = []; ws.on('message', (data) => { From fd59d35dbc49873cacaa66712fce0e60b0f7f3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 08:49:16 +0200 Subject: [PATCH 05/12] debug logs --- test/chat.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/chat.mjs b/test/chat.mjs index 20fdb194..b31e925f 100644 --- a/test/chat.mjs +++ b/test/chat.mjs @@ -40,17 +40,19 @@ describe('Chat', () => { const receivedMessages = []; ws.on('message', (data) => { + console.log('received', data); receivedMessages.push(JSON.parse(data)); }); ws.send(JSON.stringify({ command: 'sendChat', data: 'Message text' })); + console.log('waiting'); await waitFor(() => ( receivedMessages.some((message) => ( message.command === 'chatMessage' && message.data.userID === user.id && message.data.message === 'Message text' )) - ), 5_000); + ), 15_000); }); it('does not broadcast chat messages from muted users', async () => { From 8376fbf883545130ea1595bb3f0c6b5e714df4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 08:51:18 +0200 Subject: [PATCH 06/12] debug --- test/chat.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/chat.mjs b/test/chat.mjs index b31e925f..c302d881 100644 --- a/test/chat.mjs +++ b/test/chat.mjs @@ -40,7 +40,7 @@ describe('Chat', () => { const receivedMessages = []; ws.on('message', (data) => { - console.log('received', data); + console.log('received', data + ''); receivedMessages.push(JSON.parse(data)); }); From 9c332986bfec0d4f2a6878472ac66ed4ec0df73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 08:54:08 +0200 Subject: [PATCH 07/12] make the timeouts VERY long --- test/chat.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/chat.mjs b/test/chat.mjs index c302d881..e61c9f3e 100644 --- a/test/chat.mjs +++ b/test/chat.mjs @@ -40,12 +40,11 @@ describe('Chat', () => { const receivedMessages = []; ws.on('message', (data) => { - console.log('received', data + ''); receivedMessages.push(JSON.parse(data)); }); ws.send(JSON.stringify({ command: 'sendChat', data: 'Message text' })); - console.log('waiting'); + // Using very long timeouts for CI await waitFor(() => ( receivedMessages.some((message) => ( message.command === 'chatMessage' @@ -74,7 +73,8 @@ describe('Chat', () => { ws.send(JSON.stringify({ command: 'sendChat', data: 'unmuted' })); mutedWs.send(JSON.stringify({ command: 'sendChat', data: 'muted' })); - await waitFor(() => receivedMessages.length >= 2, 5_000); + // Using very long timeouts for CI + await waitFor(() => receivedMessages.length >= 2, 15_000); assert(receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === user.id)); assert(!receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === mutedUser.id)); From 2fed68852012fad90b4f02db73dd8a591d72786d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 08:59:36 +0200 Subject: [PATCH 08/12] skip flaky tests on CI --- test/chat.mjs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test/chat.mjs b/test/chat.mjs index e61c9f3e..bcebabd9 100644 --- a/test/chat.mjs +++ b/test/chat.mjs @@ -5,6 +5,7 @@ import supertest from 'supertest'; import createUwave from './utils/createUwave.mjs'; const sandbox = sinon.createSandbox(); +const skipOnCI = process.env.CI ? it.skip : it; /** * @param {() => boolean} predicate @@ -21,7 +22,6 @@ async function waitFor(predicate, timeout) { throw new Error('Timed out waiting for predicate'); } -// Can't get this to be reliable, skip for now describe('Chat', () => { let uw; @@ -33,14 +33,16 @@ describe('Chat', () => { await uw.destroy(); }); - it('can send chat messages through WebSockets', async () => { + // Flaky on CI + skipOnCI('can send chat messages through WebSockets', async () => { const user = await uw.test.createUser(); const ws = await uw.test.connectToWebSocketAs(user); const receivedMessages = []; ws.on('message', (data) => { - receivedMessages.push(JSON.parse(data)); + if (`${data}` === '-') return; + receivedMessages.push(JSON.parse(`${data}`)); }); ws.send(JSON.stringify({ command: 'sendChat', data: 'Message text' })); @@ -51,10 +53,11 @@ describe('Chat', () => { && message.data.userID === user.id && message.data.message === 'Message text' )) - ), 15_000); + ), 5_000); }); - it('does not broadcast chat messages from muted users', async () => { + // Flaky on CI + skipOnCI('does not broadcast chat messages from muted users', async () => { const user = await uw.test.createUser(); const mutedUser = await uw.test.createUser(); @@ -67,14 +70,14 @@ describe('Chat', () => { const receivedMessages = []; ws.on('message', (data) => { - receivedMessages.push(JSON.parse(data)); + if (`${data}` === '-') return; + receivedMessages.push(JSON.parse(`${data}`)); }); ws.send(JSON.stringify({ command: 'sendChat', data: 'unmuted' })); mutedWs.send(JSON.stringify({ command: 'sendChat', data: 'muted' })); - // Using very long timeouts for CI - await waitFor(() => receivedMessages.length >= 2, 15_000); + await waitFor(() => receivedMessages.length >= 2, 5_000); assert(receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === user.id)); assert(!receivedMessages.some((message) => message.command === 'chatMessage' && message.data.userID === mutedUser.id)); From c4cc67256d6fea99caeb40a75587c9644dacc4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 09:46:52 +0200 Subject: [PATCH 09/12] add error message --- locale/en.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/locale/en.yaml b/locale/en.yaml index 9959196f..d2510465 100644 --- a/locale/en.yaml +++ b/locale/en.yaml @@ -22,6 +22,7 @@ uwave: noSelfFavorite: "You can't favorite your own plays." noSelfMute: "You can't mute yourself." noSelfUnmute: "You can't unmute yourself." + chatMuted: 'You have been muted in the chat.' sourceNotFound: 'Source "{{name}}" not found.' sourceNoImport: 'Source "{{name}}" does not support importing.' tooManyNameChanges: 'You can only change your username five times per hour. Try again in {{retryAfter}}.' From 3bfbfddb1f26cabba6487777a67d1eedcea03e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 09:52:17 +0200 Subject: [PATCH 10/12] throw in chat.send if muted --- src/SocketServer.js | 8 +++++++- src/controllers/chat.js | 4 ---- src/plugins/chat.js | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/SocketServer.js b/src/SocketServer.js index 76942a11..505b0601 100644 --- a/src/SocketServer.js +++ b/src/SocketServer.js @@ -7,6 +7,7 @@ const WebSocket = require('ws'); const Ajv = require('ajv').default; const ms = require('ms'); const debug = require('debug')('uwave:api:sockets'); +const { ChatMutedError } = require('./errors'); const { socketVote } = require('./controllers/booth'); const { disconnectUser } = require('./controllers/users'); const AuthRegistry = require('./AuthRegistry'); @@ -213,7 +214,12 @@ class SocketServer { this.#clientActions = { sendChat: (user, message) => { debug('sendChat', user, message); - this.#uw.chat.send(user, message); + this.#uw.chat.send(user, message).catch((error) => { + if (error instanceof ChatMutedError) { + return; + } + debug('sendChat', error); + }); }, vote: (user, direction) => { socketVote(this.#uw, user.id, direction); diff --git a/src/controllers/chat.js b/src/controllers/chat.js index 27c832f4..f883f5e3 100644 --- a/src/controllers/chat.js +++ b/src/controllers/chat.js @@ -3,7 +3,6 @@ const { UserNotFoundError, CannotSelfMuteError, - ChatMutedError, } = require('../errors'); const toItemResponse = require('../utils/toItemResponse'); @@ -75,9 +74,6 @@ async function sendMessage(req) { const { chat } = req.uwave; const result = await chat.send(user, message); - if (!result) { - throw new ChatMutedError(); - } return toItemResponse(result); } diff --git a/src/plugins/chat.js b/src/plugins/chat.js index 5d0936a6..4e578215 100644 --- a/src/plugins/chat.js +++ b/src/plugins/chat.js @@ -1,6 +1,7 @@ 'use strict'; const randomUUID = require('crypto-randomuuid'); +const { ChatMutedError } = require('../errors'); const routes = require('../routes/chat'); /** @@ -91,7 +92,7 @@ class Chat { */ async send(user, message) { if (await this.isMuted(user)) { - return null; + throw new ChatMutedError(); } const id = randomUUID(); From ed45e0fe07d704bd58ade3c26030bee1546b9242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 10:40:53 +0200 Subject: [PATCH 11/12] Add message tags --- locale/en.yaml | 1 + src/SocketServer.js | 2 +- src/controllers/chat.js | 5 ++-- src/errors/index.js | 7 +++++ src/plugins/chat.js | 30 +++++++++++++++++--- src/redisMessages.ts | 3 ++ src/types.ts | 9 +++++- src/validations.js | 9 ++++++ test/chat.mjs | 62 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 120 insertions(+), 8 deletions(-) diff --git a/locale/en.yaml b/locale/en.yaml index d2510465..9448013f 100644 --- a/locale/en.yaml +++ b/locale/en.yaml @@ -23,6 +23,7 @@ uwave: noSelfMute: "You can't mute yourself." noSelfUnmute: "You can't unmute yourself." chatMuted: 'You have been muted in the chat.' + tooManyTags: 'Too much tag data: only up to {{maxLength}} bytes are allowed.' sourceNotFound: 'Source "{{name}}" not found.' sourceNoImport: 'Source "{{name}}" does not support importing.' tooManyNameChanges: 'You can only change your username five times per hour. Try again in {{retryAfter}}.' diff --git a/src/SocketServer.js b/src/SocketServer.js index 505b0601..1b0bbb2c 100644 --- a/src/SocketServer.js +++ b/src/SocketServer.js @@ -214,7 +214,7 @@ class SocketServer { this.#clientActions = { sendChat: (user, message) => { debug('sendChat', user, message); - this.#uw.chat.send(user, message).catch((error) => { + this.#uw.chat.send(user, { message }).catch((error) => { if (error instanceof ChatMutedError) { return; } diff --git a/src/controllers/chat.js b/src/controllers/chat.js index f883f5e3..0837b22d 100644 --- a/src/controllers/chat.js +++ b/src/controllers/chat.js @@ -63,6 +63,7 @@ async function unmuteUser(req) { /** * @typedef {object} SendMessageBody * @prop {string} message + * @prop {Partial} [tags] */ /** @@ -70,10 +71,10 @@ async function unmuteUser(req) { */ async function sendMessage(req) { const { user } = req; - const { message } = req.body; + const { message, tags } = req.body; const { chat } = req.uwave; - const result = await chat.send(user, message); + const result = await chat.send(user, { message, tags }); return toItemResponse(result); } diff --git a/src/errors/index.js b/src/errors/index.js index beccf9ba..cc778fd6 100644 --- a/src/errors/index.js +++ b/src/errors/index.js @@ -213,6 +213,12 @@ const ChatMutedError = createErrorClass('ChatMutedError', { base: Forbidden, }); +const TooManyTagsError = createErrorClass('TooManyTagsError', { + code: 'too-many-tags', + string: 'errors.tooManyTags', + base: BadRequest, +}); + const SourceNotFoundError = createErrorClass('SourceNotFoundError', { code: 'source-not-found', string: 'errors.sourceNotFound', @@ -277,6 +283,7 @@ exports.ItemNotInPlaylistError = ItemNotInPlaylistError; exports.CannotSelfFavoriteError = CannotSelfFavoriteError; exports.CannotSelfMuteError = CannotSelfMuteError; exports.ChatMutedError = ChatMutedError; +exports.TooManyTagsError = TooManyTagsError; exports.SourceNotFoundError = SourceNotFoundError; exports.SourceNoImportError = SourceNoImportError; exports.EmptyPlaylistError = EmptyPlaylistError; diff --git a/src/plugins/chat.js b/src/plugins/chat.js index 4e578215..2fb6e790 100644 --- a/src/plugins/chat.js +++ b/src/plugins/chat.js @@ -1,7 +1,7 @@ 'use strict'; const randomUUID = require('crypto-randomuuid'); -const { ChatMutedError } = require('../errors'); +const { ChatMutedError, TooManyTagsError } = require('../errors'); const routes = require('../routes/chat'); /** @@ -9,6 +9,10 @@ const routes = require('../routes/chat'); * * @typedef {object} ChatOptions * @prop {number} maxLength + * + * @typedef {object} ChatMessage + * @prop {string} message + * @prop {Partial} [tags] */ /** @type {ChatOptions} */ @@ -88,25 +92,43 @@ class Chat { /** * @param {User} user - * @param {string} message + * @param {ChatMessage} data */ - async send(user, message) { + async send(user, { message, tags }) { + const { acl } = this.#uw; + + const maxLength = 2048; + if (tags && JSON.stringify(tags).length > maxLength) { + throw new TooManyTagsError({ maxLength }); + } + if (await this.isMuted(user)) { throw new ChatMutedError(); } + const permissions = tags ? await acl.getAllPermissions(user) : []; + const globalTags = new Set(['id', 'replyTo']); + const filteredTags = Object.fromEntries( + Object.entries(tags ?? {}) + .filter(([name]) => globalTags.has(name) || permissions.includes(name)), + ); + const id = randomUUID(); + const timestamp = Date.now(); const truncatedMessage = this.truncate(message); this.#uw.publish('chat:message', { id, userID: user.id, message: truncatedMessage, - timestamp: Date.now(), + timestamp, + tags: filteredTags, }); return { _id: id, message: truncatedMessage, + timestamp, + tags: filteredTags, }; } diff --git a/src/redisMessages.ts b/src/redisMessages.ts index ef0a9a15..b6a1527a 100644 --- a/src/redisMessages.ts +++ b/src/redisMessages.ts @@ -1,3 +1,5 @@ +import type { ChatTags } from './types'; + export type ServerActionParameters = { 'advance:complete': { historyID: string, @@ -35,6 +37,7 @@ export type ServerActionParameters = { userID: string, message: string, timestamp: number, + tags?: Partial, }, 'chat:delete': { filter: { id: string } | { userID: string } | Record, diff --git a/src/types.ts b/src/types.ts index 16722cab..4115775a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ import type { Model } from 'mongoose'; import type { ParsedQs } from 'qs'; -import type { JsonObject } from 'type-fest'; +import type { JsonObject, JsonValue } from 'type-fest'; import type { Request as ExpressRequest, Response as ExpressResponse } from 'express'; import type UwaveServer from './Uwave'; import type { HttpApi } from './HttpApi'; @@ -85,3 +85,10 @@ type OffsetPaginationQuery = { page?: { offset?: string, limit?: string }, }; export type PaginationQuery = LegacyPaginationQuery | OffsetPaginationQuery; + +/** Well known chat message tag types. */ +export type ChatTags = { + id: string, + replyTo: string, + [key: `${string}:${string}`]: JsonValue, +} diff --git a/src/validations.js b/src/validations.js index c37a226b..9762bdd6 100644 --- a/src/validations.js +++ b/src/validations.js @@ -219,6 +219,15 @@ exports.sendChatMessage = /** @type {const} */ ({ type: 'object', properties: { message: { type: 'string', minLength: 1 }, + tags: { + type: 'object', + properties: { + id: { type: 'string', maxLength: 40 }, + replyTo: { type: 'string', minLength: 1, maxLength: 40 }, + }, + // In the future we should support custom tags, maybe namespaced with a : in the name. + additionalProperties: false, + }, }, required: ['message'], }, diff --git a/test/chat.mjs b/test/chat.mjs index bcebabd9..7b4ae896 100644 --- a/test/chat.mjs +++ b/test/chat.mjs @@ -2,6 +2,7 @@ import assert from 'assert'; import sinon from 'sinon'; import delay from 'delay'; import supertest from 'supertest'; +import randomString from 'random-string'; import createUwave from './utils/createUwave.mjs'; const sandbox = sinon.createSandbox(); @@ -120,6 +121,66 @@ describe('Chat', () => { .expect(400); }); + it('validates tags', async () => { + const user = await uw.test.createUser(); + const token = await uw.test.createTestSessionToken(user); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: 'a message', tags: null }) + .expect(400); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: 'a message', tags: {} }) + .expect(200); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: 'a message', tags: { replyTo: 2 } }) + .expect(400); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: 'a message', tags: { id: 1 } }) + .expect(400); + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ + message: 'a message', + tags: { + id: '9e32b0f7-889b-40b3-b59b-60ed06a07890', + replyTo: '60bbdb3a-d9c3-42a8-be27-eb2574ad0ed4', + }, + }) + .expect(200); + + const res = await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: 'a message', tags: { unknown: '' } }) + .expect(200); + assert.deepStrictEqual(res.body.data.tags, {}, 'Unknown tags removed'); + + const aLotOfTags = {}; + for (let i = 0; i < 20; i += 1) { + aLotOfTags[`test:${randomString({ length: 16 })}`] = randomString({ length: 200 }); + } + + await supertest(uw.server) + .post('/api/chat') + .set('Cookie', `uwsession=${token}`) + .send({ message: 'a message', tags: aLotOfTags }) + // TODO This should return 400 when namespaced tags are supported. + .expect(200); + }); + it('broadcasts a chat message', async () => { const user = await uw.test.createUser(); const token = await uw.test.createTestSessionToken(user); @@ -133,6 +194,7 @@ describe('Chat', () => { sinon.assert.match(res.body.data, { _id: sinon.match.string, message: sinon.match.string, + tags: {}, }); const receivedMessages = []; From b44ba6052165ad3e261476335c0a68f87025b8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 22 Apr 2022 11:13:05 +0200 Subject: [PATCH 12/12] fix lint --- src/plugins/chat.js | 10 ++++++---- src/redisMessages.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/plugins/chat.js b/src/plugins/chat.js index 2fb6e790..016c8e8f 100644 --- a/src/plugins/chat.js +++ b/src/plugins/chat.js @@ -108,10 +108,12 @@ class Chat { const permissions = tags ? await acl.getAllPermissions(user) : []; const globalTags = new Set(['id', 'replyTo']); - const filteredTags = Object.fromEntries( - Object.entries(tags ?? {}) - .filter(([name]) => globalTags.has(name) || permissions.includes(name)), - ); + const filteredTags = tags + ? Object.fromEntries( + Object.entries(tags) + .filter(([name]) => globalTags.has(name) || permissions.includes(name)), + ) + : {}; const id = randomUUID(); const timestamp = Date.now(); diff --git a/src/redisMessages.ts b/src/redisMessages.ts index b6a1527a..951803f1 100644 --- a/src/redisMessages.ts +++ b/src/redisMessages.ts @@ -1,4 +1,4 @@ -import type { ChatTags } from './types'; +import type { ChatTags } from './types'; // eslint-disable-line node/no-missing-import export type ServerActionParameters = { 'advance:complete': {