From aa71a302f6b8964acd4e1d95a771c87c17f6bdc8 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Thu, 21 Sep 2023 19:32:47 +0200 Subject: [PATCH] Channel Configuration UI WIP --- .../common/configuration/templates/channel.ts | 20 +-- .../configuration/templates/logic/channel.ts | 54 ++++++-- server/lib/configuration/channel/sanitize.ts | 127 +++++++++++++++++- server/lib/configuration/channel/storage.ts | 115 +++++++++++++--- server/lib/routers/api/configuration.ts | 10 ++ shared/lib/types.ts | 23 +++- 6 files changed, 296 insertions(+), 53 deletions(-) diff --git a/client/common/configuration/templates/channel.ts b/client/common/configuration/templates/channel.ts index 4550b023..474ccbf8 100644 --- a/client/common/configuration/templates/channel.ts +++ b/client/common/configuration/templates/channel.ts @@ -47,9 +47,9 @@ async function renderConfigurationChannel ( name="bot" id="peertube-livechat-bot" value="1" - {{#channelConfiguration.configuration.bot}} + {{#channelConfiguration.configuration.bot.enabled}} checked="checked" - {{/channelConfiguration.configuration.bot}} + {{/channelConfiguration.configuration.bot.enabled}} /> {{enableBot}} @@ -61,7 +61,7 @@ async function renderConfigurationChannel ( name="bot_nickname" class="form-control" id="peertube-livechat-bot-nickname" - value="{{channelConfiguration.configuration.botNickname}}" + value="{{channelConfiguration.configuration.bot.nickname}}" /> @@ -84,11 +84,7 @@ async function renderConfigurationChannel ( name="forbidden_words_{{fieldNumber}}" id="peertube-livechat-forbidden-words-{{fieldNumber}}" class="form-control" - >{{ - #channelConfiguration.configuration.forbiddenWords - }}{{.}}\n{{ - /channelConfiguration.configuration.forbiddenWords - }} + >{{entries}}

{{forbiddenWordsDesc2}}

@@ -97,6 +93,9 @@ async function renderConfigurationChannel ( type="checkbox" name="forbidden_words_regexp_{{fieldNumber}}" value="1" + {{#regexp}} + checked="checked" + {{/regexp}} /> {{forbiddenWordsRegexp}} @@ -108,6 +107,9 @@ async function renderConfigurationChannel ( type="checkbox" name="forbidden_words_applytomoderators_{{fieldNumber}}" value="1" + {{#applyToModerators}} + checked="checked" + {{/applyToModerators}} /> {{forbiddenWordsApplyToModerators}} @@ -120,7 +122,7 @@ async function renderConfigurationChannel ( name="forbidden_words_reason_{{fieldNumber}}" class="form-control" id="peertube-livechat-forbidden-words-reason-{{fieldNumber}}" - value="" + value="{{reason}}" />

{{forbiddenWordsReasonDesc}}

diff --git a/client/common/configuration/templates/logic/channel.ts b/client/common/configuration/templates/logic/channel.ts index d1b4eff4..ce57d4aa 100644 --- a/client/common/configuration/templates/logic/channel.ts +++ b/client/common/configuration/templates/logic/channel.ts @@ -33,15 +33,23 @@ async function getConfigurationChannelViewData ( throw new Error('Invalid channel configuration options.') } + const forbiddenWordsArray = [] + for (let i = 0; i < channelConfiguration.configuration.bot.forbiddenWords.length; i++) { + const fw = channelConfiguration.configuration.bot.forbiddenWords[i] + forbiddenWordsArray.push({ + displayNumber: i + 1, + fieldNumber: i, + displayHelp: i === 0, + entries: fw.entries.join('\n'), + regexp: !!fw.regexp, + applyToModerators: fw.applyToModerators, + reason: fw.reason + }) + } + return { channelConfiguration, - forbiddenWordsArray: [0, 1, 2].map(count => { - return { - displayNumber: count + 1, - fieldNumber: count, - displayHelp: count === 0 - } - }), + forbiddenWordsArray, quotesArray: [0].map(count => { return { displayNumber: count + 1, @@ -89,12 +97,36 @@ async function vivifyConfigurationChannel ( const submitForm: Function = async () => { const data = new FormData(form) const channelConfigurationOptions: ChannelConfigurationOptions = { - bot: data.get('bot') === '1', - botNickname: data.get('bot_nickname')?.toString() ?? '', - // bannedJIDs: (data.get('banned_jids')?.toString() ?? '').split(/\r?\n|\r|\n/g), - forbiddenWords: (data.get('forbidden_words')?.toString() ?? '').split(/\r?\n|\r|\n/g) + bot: { + enabled: data.get('bot') === '1', + nickname: data.get('bot_nickname')?.toString() ?? '', + // TODO bannedJIDs + forbiddenWords: [], + quotes: [], + commands: [] + } } + // TODO: handle form errors. + + for (let i = 0; data.has('forbidden_words_' + i.toString()); i++) { + const entries = (data.get('forbidden_words_' + i.toString())?.toString() ?? '').split(/\r?\n|\r|\n/g) + const regexp = data.get('forbidden_words_regexp_' + i.toString()) + const applyToModerators = data.get('forbidden_words_applytomoderators_' + i.toString()) + const reason = data.get('forbidden_words_reason_' + i.toString())?.toString() + const fw: ChannelConfigurationOptions['bot']['forbiddenWords'][0] = { + entries, + applyToModerators: !!applyToModerators, + regexp: !!regexp + } + if (reason) { + fw.reason = reason + } + channelConfigurationOptions.bot.forbiddenWords.push(fw) + } + + // TODO: quotes and commands. + const headers: any = clientOptions.peertubeHelpers.getAuthHeader() ?? {} headers['content-type'] = 'application/json;charset=UTF-8' diff --git a/server/lib/configuration/channel/sanitize.ts b/server/lib/configuration/channel/sanitize.ts index 13f38978..abcf6cec 100644 --- a/server/lib/configuration/channel/sanitize.ts +++ b/server/lib/configuration/channel/sanitize.ts @@ -18,11 +18,20 @@ async function sanitizeChannelConfigurationOptions ( throw new Error('Invalid data type') } + const botData = data.bot + if (typeof botData !== 'object') { + throw new Error('Invalid data.bot data type') + } + const result: ChannelConfigurationOptions = { - bot: _readBoolean(data, 'bot'), - botNickname: _readSimpleInput(data, 'botNickname'), - // bannedJIDs: await _readRegExpArray(data, 'bannedJIDs'), - forbiddenWords: await _readRegExpArray(data, 'forbiddenWords') + bot: { + enabled: _readBoolean(botData, 'enabled'), + nickname: _readSimpleInput(botData, 'nickname', true), + forbiddenWords: await _readForbiddenWords(botData), + quotes: _readQuotes(botData), + commands: _readCommands(botData) + // TODO: bannedJIDs + } } return result @@ -38,15 +47,59 @@ function _readBoolean (data: any, f: string): boolean { return data[f] } -function _readSimpleInput (data: any, f: string): string { +function _readInteger (data: any, f: string, min: number, max: number): number { + if (!(f in data)) { + throw new Error('Missing integer value for field ' + f) + } + const v = parseInt(data[f]) + if (isNaN(v)) { + throw new Error('Invalid value type for field ' + f) + } + if (v < min) { + throw new Error('Invalid value type ( max) { + throw new Error('Invalid value type (>max) for field ' + f) + } + return v +} + +function _readSimpleInput (data: any, f: string, strict?: boolean): string { if (!(f in data)) { return '' } if (typeof data[f] !== 'string') { throw new Error('Invalid data type for field ' + f) } - // Replacing all invalid characters, no need to throw an error.. - return (data[f] as string).replace(/[^\p{L}\p{N}\p{Z}_-]$/gu, '') + // Removing control characters. + // eslint-disable-next-line no-control-regex + let s = (data[f] as string).replace(/[\u0000-\u001F\u007F-\u009F]/g, '') + if (strict) { + // Replacing all invalid characters, no need to throw an error.. + s = s.replace(/[^\p{L}\p{N}\p{Z}_-]$/gu, '') + } + return s +} + +function _readStringArray (data: any, f: string): string[] { + if (!(f in data)) { + return [] + } + if (!Array.isArray(data[f])) { + throw new Error('Invalid data type for field ' + f) + } + const result: string[] = [] + for (const v of data[f]) { + if (typeof v !== 'string') { + throw new Error('Invalid data type in a value of field ' + f) + } + if (v === '' || /^\s+$/.test(v)) { + // ignore empty values + continue + } + result.push(v) + } + return result } async function _readRegExpArray (data: any, f: string): Promise { @@ -82,6 +135,66 @@ async function _readRegExpArray (data: any, f: string): Promise { return result } +async function _readForbiddenWords (botData: any): Promise { + if (!Array.isArray(botData.forbiddenWords)) { + throw new Error('Invalid forbiddenWords data') + } + const result: ChannelConfigurationOptions['bot']['forbiddenWords'] = [] + for (const fw of botData.forbiddenWords) { + const regexp = !!fw.regexp + let entries + if (regexp) { + entries = await _readRegExpArray(fw, 'entries') + } else { + entries = _readStringArray(fw, 'entries') + } + const applyToModerators = _readBoolean(fw, 'applyToModerators') + const reason = fw.reason ? _readSimpleInput(fw, 'reason') : undefined + + result.push({ + regexp, + entries, + applyToModerators, + reason + }) + } + return result +} + +function _readQuotes (botData: any): ChannelConfigurationOptions['bot']['quotes'] { + if (!Array.isArray(botData.quotes)) { + throw new Error('Invalid quotes data') + } + const result: ChannelConfigurationOptions['bot']['quotes'] = [] + for (const fw of botData.quotes) { + const messages = _readStringArray(fw, 'message') + const delay = _readInteger(fw, 'delay', 1, 6000) + + result.push({ + messages, + delay + }) + } + return result +} + +function _readCommands (botData: any): ChannelConfigurationOptions['bot']['commands'] { + if (!Array.isArray(botData.commands)) { + throw new Error('Invalid commands data') + } + const result: ChannelConfigurationOptions['bot']['commands'] = [] + for (const fw of botData.commands) { + const message = _readSimpleInput(fw, 'message') + const command = _readSimpleInput(fw, 'command') + + result.push({ + message, + command + }) + } + return result +} + export { sanitizeChannelConfigurationOptions } diff --git a/server/lib/configuration/channel/storage.ts b/server/lib/configuration/channel/storage.ts index d0ceb3cd..abc51e4d 100644 --- a/server/lib/configuration/channel/storage.ts +++ b/server/lib/configuration/channel/storage.ts @@ -36,10 +36,43 @@ async function getChannelConfigurationOptions ( function getDefaultChannelConfigurationOptions (_options: RegisterServerOptions): ChannelConfigurationOptions { return { - bot: false, - botNickname: 'Sepia', - // bannedJIDs: [], - forbiddenWords: [] + bot: { + enabled: false, + nickname: 'Sepia', + // Note: we are instanciating several data for forbiddenWords, quotes and commands. + // This will be used by the frontend to instanciates requires fields + forbiddenWords: [ + { + entries: [] + }, + { + entries: [] + }, + { + entries: [] + } + ], + quotes: [ + { + messages: [], + delay: 5 * 60 // seconds to minutes + } + ], + commands: [ + { + command: '', + message: '' + }, + { + command: '', + message: '' + }, + { + command: '', + message: '' + } + ] + } } } @@ -86,25 +119,26 @@ function channelConfigurationOptionsToBotRoomConf ( // If we want the bot to correctly enable/disable the handlers, // we must always define all handlers, even if not used. const handlers: ConfigHandlers = [] - handlers.push(_getForbiddenWordsHandler( - 'forbidden_words_0', - channelConfigurationOptions.forbiddenWords - )) + channelConfigurationOptions.bot.forbiddenWords.forEach((v, i) => { + handlers.push(_getForbiddenWordsHandler( + 'forbidden_words_' + i.toString(), + channelConfigurationOptions.bot.forbiddenWords[i] + )) + }) const roomConf: ChannelCommonRoomConf = { - enabled: channelConfigurationOptions.bot, + enabled: channelConfigurationOptions.bot.enabled, handlers } - if (channelConfigurationOptions.botNickname && channelConfigurationOptions.botNickname !== '') { - roomConf.nick = channelConfigurationOptions.botNickname + if (channelConfigurationOptions.bot.nickname && channelConfigurationOptions.bot.nickname !== '') { + roomConf.nick = channelConfigurationOptions.bot.nickname } return roomConf } function _getForbiddenWordsHandler ( id: string, - forbiddenWords: string[], - reason?: string + forbiddenWords: ChannelConfigurationOptions['bot']['forbiddenWords'][0] ): ConfigHandler { const handler: ConfigHandler = { type: 'moderate', @@ -114,26 +148,63 @@ function _getForbiddenWordsHandler ( rules: [] } } - if (forbiddenWords.length === 0) { + if (forbiddenWords.entries.length === 0) { return handler } handler.enabled = true - // Note: on the Peertube frontend, channelConfigurationOptions.forbiddenWords - // is an array of RegExp definition (strings). - // They are validated one by bone. - // To increase the bot performance, we will join them all (hopping the bot will optimize them). const rule: any = { - name: id, - regexp: '(?:' + forbiddenWords.join(')|(?:') + ')' + name: id } - if (reason) { - rule.reason = reason + + if (forbiddenWords.regexp) { + // Note: on the Peertube frontend, channelConfigurationOptions.forbiddenWords + // is an array of RegExp definition (strings). + // They are validated one by bone. + // To increase the bot performance, we will join them all (hopping the bot will optimize them). + rule.regexp = '(?:' + forbiddenWords.entries.join(')|(?:') + ')' + } else { + // Here we must add word-breaks and escape entries. + // We join all entries in one Regexp (for the same reason as above). + rule.regexp = '(?:' + + forbiddenWords.entries.map(s => { + s = _stringToWordRegexp(s) + // Must add the \b... + // ... but... won't work if the first (or last) char is an emoji. + // So, doing this trick: + if (/^\w/.test(s)) { + s = '\\b' + s + } + if (/\w$/.test(s)) { + s = s + '\\b' + } + // FIXME: this solution wont work for non-latin charsets. + return s + }).join(')|(?:') + ')' + } + + if (forbiddenWords.reason) { + rule.reason = forbiddenWords.reason } handler.options.rules.push(rule) + + handler.options.applyToModerators = !!forbiddenWords.applyToModerators return handler } +const stringToWordRegexpSpecials = [ + // order matters for these + '-', '[', ']', + // order doesn't matter for any of these + '/', '{', '}', '(', ')', '*', '+', '?', '.', '\\', '^', '$', '|' +] +// I choose to escape every character with '\' +// even though only some strictly require it when inside of [] +const stringToWordRegexp = RegExp('[' + stringToWordRegexpSpecials.join('\\') + ']', 'g') +function _stringToWordRegexp (s: string): string { + return s.replace(stringToWordRegexp, '\\$&') +} + function _getFilePath ( options: RegisterServerOptions, channelId: number | string diff --git a/server/lib/routers/api/configuration.ts b/server/lib/routers/api/configuration.ts index 5eca59af..9b176747 100644 --- a/server/lib/routers/api/configuration.ts +++ b/server/lib/routers/api/configuration.ts @@ -55,6 +55,16 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route // Note: the front-end should do some input validation. // If there is any invalid value, we just return a 400 error. // The frontend should have prevented to post invalid data. + + // Note: if !bot.enabled, we wont try to save hidden fields values, to minimize the risk of error + if (req.body.bot?.enabled === false) { + logger.debug('Bot disabled, loading the previous bot conf to not override hidden fields') + const channelOptions = + await getChannelConfigurationOptions(options, channelInfos.id) ?? + getDefaultChannelConfigurationOptions(options) + req.body.bot = channelOptions.bot + req.body.bot.enable = false + } channelOptions = await sanitizeChannelConfigurationOptions(options, channelInfos.id, req.body) } catch (err) { logger.warn(err) diff --git a/shared/lib/types.ts b/shared/lib/types.ts index eb6d57e6..e75c5205 100644 --- a/shared/lib/types.ts +++ b/shared/lib/types.ts @@ -53,10 +53,25 @@ interface ChannelInfos { } interface ChannelConfigurationOptions { - bot: boolean - botNickname?: string - forbiddenWords: string[] - // TODO: bannedJIDs: string[] + bot: { + enabled: boolean + nickname?: string + forbiddenWords: Array<{ + entries: string[] + regexp?: boolean + applyToModerators?: boolean + reason?: string + }> + quotes: Array<{ + messages: string[] + delay: number + }> + commands: Array<{ + command: string + message: string + }> + // TODO: bannedJIDs: string[] + } } interface ChannelConfiguration {