// SPDX-FileCopyrightText: 2024 John Livingston // // SPDX-License-Identifier: AGPL-3.0-only import type { RegisterServerOptions } from '@peertube/peertube-types' import type { ChannelConfigurationOptions } from '../../../../shared/lib/types' import { channelTermsMaxLength, forbidSpecialCharsMaxTolerance, forbidSpecialCharsDefaultTolerance, noDuplicateDefaultDelay, noDuplicateMaxDelay } from '../../../../shared/lib/constants' import * as RE2 from 're2' type SanitizeMode = 'validation' | 'read' /** * Sanitize data so that they can safely be used/stored for channel configuration configuration. * Throw an error if the format is obviously wrong. * Cleans data (removing empty values, ...) * @param options Peertube server options * @param _channelInfos Channel infos * @param data Input data * @param mode Sanitization mode. 'validation': when verifiying user input. 'read': when reading from disk. */ async function sanitizeChannelConfigurationOptions ( _options: RegisterServerOptions, _channelId: number | string, data: unknown, mode: SanitizeMode ): Promise { if (!_assertObjectType(data)) { throw new Error('Invalid data type') } const botData = data.bot ?? {} if (!_assertObjectType(botData)) { throw new Error('Invalid data.bot data type') } // slowMode not present in livechat <= 8.2.0: const slowModeData = data.slowMode ?? {} if (!_assertObjectType(slowModeData)) { throw new Error('Invalid data.slowMode data type') } slowModeData.duration ??= slowModeData.defaultDuration ?? 0 // v8.3.0 to 8.3.2: was in defaultDuration const moderationData = data.moderation ?? {} // comes with livechat 10.3.0 if (!_assertObjectType(moderationData)) { throw new Error('Invalid data.moderation data type') } moderationData.delay ??= 0 moderationData.anonymize ??= false // comes with livechat 11.0.0 // mute not present in livechat <= 10.2.0 const mute = data.mute ?? {} if (!_assertObjectType(mute)) { throw new Error('Invalid data.mute data type') } mute.anonymous ??= false // forbidSpecialChars comes with livechat 12.0.0 botData.forbidSpecialChars ??= { enabled: false, reason: '', tolerance: forbidSpecialCharsDefaultTolerance, applyToModerators: false } if (!_assertObjectType(botData.forbidSpecialChars)) { throw new Error('Invalid data.bot.forbidSpecialChars data type') } // noDuplicate comes with livechat 12.0.0 botData.noDuplicate ??= { enabled: false, reason: '', delay: noDuplicateDefaultDelay, applyToModerators: false } if (!_assertObjectType(botData.noDuplicate)) { throw new Error('Invalid data.bot.noDuplicate data type') } // terms not present in livechat <= 10.2.0 let terms = data.terms ?? undefined if (terms !== undefined && (typeof terms !== 'string')) { throw new Error('Invalid data.terms data type') } if (terms && terms.length > channelTermsMaxLength) { throw new Error('data.terms value too long') } if (terms === '') { terms = undefined } const result: ChannelConfigurationOptions = { bot: { enabled: _readBoolean(botData, 'enabled'), nickname: _readSimpleInput(botData, 'nickname', true), forbiddenWords: await _readForbiddenWords(botData, mode), forbidSpecialChars: await _readForbidSpecialChars(botData), noDuplicate: await _readNoDuplicate(botData), quotes: _readQuotes(botData), commands: _readCommands(botData) // TODO: bannedJIDs }, slowMode: { duration: _readInteger(slowModeData, 'duration', 0, 1000) }, mute: { anonymous: _readBoolean(mute, 'anonymous') }, moderation: { delay: _readInteger(moderationData, 'delay', 0, 60), anonymize: _readBoolean(moderationData, 'anonymize') } } if (terms !== undefined) { result.terms = terms } return result } function _assertObjectType (data: unknown): data is Record { return !!data && (typeof data === 'object') && Object.keys(data).every(k => typeof k === 'string') } function _readBoolean (data: Record, f: string): boolean { if (!(f in data)) { return false } if (typeof data[f] !== 'boolean') { throw new Error('Invalid data type for field ' + f) } return data[f] } function _readInteger (data: Record, f: string, min: number, max: number): number { if (!(f in data)) { throw new Error('Missing integer value for field ' + f) } const v = typeof data[f] === 'number' ? Math.trunc(data[f]) : parseInt(data[f] as string) 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: Record, f: string, strict?: boolean, noSpace?: boolean): string { if (!(f in data)) { return '' } if (typeof data[f] !== 'string') { throw new Error('Invalid data type for field ' + f) } // Removing control characters. // eslint-disable-next-line no-control-regex let s = data[f].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, '') } if (noSpace) { s = s.replace(/\s+/g, '') } return s } function _readStringArray (data: Record, 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 } function _readMultiLineString (data: Record, f: string): string { if (!(f in data)) { return '' } if (typeof data[f] !== 'string') { throw new Error('Invalid data type for field ' + f) } // Removing control characters (must authorize \u001A: line feed) // eslint-disable-next-line no-control-regex const s = data[f].replace(/[\u0000-\u0009\u001B-\u001F\u007F-\u009F]/g, '') return s } async function _readRegExpArray (data: Record, f: string, mode: SanitizeMode): Promise { // Note: this function can instanciate a lot of RegExp. // To avoid freezing the server, we make it async, and will validate each regexp in a separate tick. 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 } // value must be a valid RE2 regexp try { async function _validate (v: string): Promise { // Before livechat v13, the bot was using RegExp. // Now it is using RE2, to avoid ReDOS attacks. // RE2 does not accept all regular expressions. // So, here come the question about settings saved before... // So we introduce the "mode" parameter. // When reading from disk, we want to be more permissive. // When validating frontend data, we want to be more restrictive. // Note: the bot will simply ignore any invalid RE2 expression, and generate an error log on loading. if (mode === 'read') { // eslint-disable-next-line no-new new RegExp(v) } else { // eslint-disable-next-line no-new, new-cap new RE2.default(v) } } await _validate(v) } catch (err: any) { throw new ChannelConfigurationValidationError('Invalid value in field ' + f, err.toString() as string) } result.push(v) } return result } async function _readForbiddenWords ( botData: Record, mode: SanitizeMode ): Promise { if (!Array.isArray(botData.forbiddenWords)) { throw new Error('Invalid forbiddenWords data') } const result: ChannelConfigurationOptions['bot']['forbiddenWords'] = [] for (const fw of botData.forbiddenWords) { if (!_assertObjectType(fw)) { throw new Error('Invalid entry in botData.forbiddenWords') } const regexp = !!fw.regexp let entries if (regexp) { entries = await _readRegExpArray(fw, 'entries', mode) } else { entries = _readStringArray(fw, 'entries') } const applyToModerators = _readBoolean(fw, 'applyToModerators') const label = fw.label ? _readSimpleInput(fw, 'label') : undefined const reason = fw.reason ? _readSimpleInput(fw, 'reason') : undefined const comments = fw.comments ? _readMultiLineString(fw, 'comments') : undefined result.push({ regexp, entries, applyToModerators, label, reason, comments }) } return result } async function _readForbidSpecialChars ( botData: Record ): Promise { if (!_assertObjectType(botData.forbidSpecialChars)) { throw new Error('Invalid forbidSpecialChars data') } const result: ChannelConfigurationOptions['bot']['forbidSpecialChars'] = { enabled: _readBoolean(botData.forbidSpecialChars, 'enabled'), reason: _readSimpleInput(botData.forbidSpecialChars, 'reason'), tolerance: _readInteger(botData.forbidSpecialChars, 'tolerance', 0, forbidSpecialCharsMaxTolerance), applyToModerators: _readBoolean(botData.forbidSpecialChars, 'applyToModerators') } return result } async function _readNoDuplicate ( botData: Record ): Promise { if (!_assertObjectType(botData.noDuplicate)) { throw new Error('Invalid forbidSpecialChars data') } const result: ChannelConfigurationOptions['bot']['noDuplicate'] = { enabled: _readBoolean(botData.noDuplicate, 'enabled'), reason: _readSimpleInput(botData.noDuplicate, 'reason'), delay: _readInteger(botData.noDuplicate, 'delay', 0, noDuplicateMaxDelay), applyToModerators: _readBoolean(botData.noDuplicate, 'applyToModerators') } return result } function _readQuotes (botData: Record): ChannelConfigurationOptions['bot']['quotes'] { if (!Array.isArray(botData.quotes)) { throw new Error('Invalid quotes data') } const result: ChannelConfigurationOptions['bot']['quotes'] = [] for (const qs of botData.quotes) { if (!_assertObjectType(qs)) { throw new Error('Invalid entry in botData.quotes') } const messages = _readStringArray(qs, 'messages') const delay = _readInteger(qs, 'delay', 1, 6000) result.push({ messages, delay }) } return result } function _readCommands (botData: Record): ChannelConfigurationOptions['bot']['commands'] { if (!Array.isArray(botData.commands)) { throw new Error('Invalid commands data') } const result: ChannelConfigurationOptions['bot']['commands'] = [] for (const cs of botData.commands) { if (!_assertObjectType(cs)) { throw new Error('Invalid entry in botData.commands') } const message = _readSimpleInput(cs, 'message') const command = _readSimpleInput(cs, 'command', false, true) result.push({ message, command }) } return result } class ChannelConfigurationValidationError extends Error { /** * The message for the frontend. */ public validationErrorMessage: string constructor (message: string | undefined, validationErrorMessage: string) { super(message) this.validationErrorMessage = validationErrorMessage } } export { sanitizeChannelConfigurationOptions }