2024-05-23 09:42:14 +00:00
|
|
|
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
|
|
|
|
//
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2023-08-09 14:16:02 +00:00
|
|
|
import type { RegisterServerOptions } from '@peertube/peertube-types'
|
2023-09-18 10:23:35 +00:00
|
|
|
import type { ChannelConfigurationOptions } from '../../../../shared/lib/types'
|
2024-06-21 16:18:11 +00:00
|
|
|
import { channelTermsMaxLength } from '../../../../shared/lib/constants'
|
2023-08-09 14:16:02 +00:00
|
|
|
|
|
|
|
/**
|
2023-09-06 13:23:39 +00:00
|
|
|
* Sanitize data so that they can safely be used/stored for channel configuration configuration.
|
2023-08-09 14:16:02 +00:00
|
|
|
* 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
|
|
|
|
*/
|
2023-09-06 13:23:39 +00:00
|
|
|
async function sanitizeChannelConfigurationOptions (
|
2023-08-09 14:16:02 +00:00
|
|
|
_options: RegisterServerOptions,
|
2023-09-18 10:23:35 +00:00
|
|
|
_channelId: number | string,
|
2024-09-07 12:49:27 +00:00
|
|
|
data: unknown
|
2023-09-06 13:23:39 +00:00
|
|
|
): Promise<ChannelConfigurationOptions> {
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(data)) {
|
2023-08-09 14:16:02 +00:00
|
|
|
throw new Error('Invalid data type')
|
|
|
|
}
|
2023-09-19 16:56:39 +00:00
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
const botData = data.bot ?? {}
|
|
|
|
if (!_assertObjectType(botData)) {
|
2023-09-21 17:32:47 +00:00
|
|
|
throw new Error('Invalid data.bot data type')
|
|
|
|
}
|
|
|
|
|
2024-02-16 14:16:44 +00:00
|
|
|
// slowMode not present in livechat <= 8.2.0:
|
|
|
|
const slowModeData = data.slowMode ?? {}
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(slowModeData)) {
|
2024-02-13 11:49:22 +00:00
|
|
|
throw new Error('Invalid data.slowMode data type')
|
|
|
|
}
|
2024-09-07 12:49:27 +00:00
|
|
|
slowModeData.duration ??= slowModeData.defaultDuration ?? 0 // v8.3.0 to 8.3.2: was in defaultDuration
|
2024-02-13 11:49:22 +00:00
|
|
|
|
2024-07-09 14:15:07 +00:00
|
|
|
const moderationData = data.moderation ?? {} // comes with livechat 10.3.0
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(moderationData)) {
|
|
|
|
throw new Error('Invalid data.moderation data type')
|
|
|
|
}
|
2024-07-09 14:15:07 +00:00
|
|
|
moderationData.delay ??= 0
|
2024-07-26 15:04:14 +00:00
|
|
|
moderationData.anonymize ??= false // comes with livechat 11.0.0
|
2024-07-09 14:15:07 +00:00
|
|
|
|
2024-06-20 14:46:14 +00:00
|
|
|
// mute not present in livechat <= 10.2.0
|
|
|
|
const mute = data.mute ?? {}
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(mute)) {
|
|
|
|
throw new Error('Invalid data.mute data type')
|
|
|
|
}
|
2024-06-20 14:46:14 +00:00
|
|
|
mute.anonymous ??= false
|
|
|
|
|
2024-09-06 22:46:23 +00:00
|
|
|
// forbidSpecialChars comes with livechat 11.1.0
|
|
|
|
botData.forbidSpecialChars ??= {
|
|
|
|
enabled: false,
|
|
|
|
reason: '',
|
|
|
|
tolerance: 0,
|
|
|
|
applyToModerators: false
|
|
|
|
}
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(botData.forbidSpecialChars)) {
|
2024-09-10 16:59:56 +00:00
|
|
|
throw new Error('Invalid data.bot.forbidSpecialChars data type')
|
|
|
|
}
|
|
|
|
|
|
|
|
// noDuplicate comes with livechat 11.1.0
|
|
|
|
botData.noDuplicate ??= {
|
|
|
|
enabled: false,
|
|
|
|
reason: '',
|
|
|
|
delay: 60,
|
|
|
|
applyToModerators: false
|
|
|
|
}
|
|
|
|
if (!_assertObjectType(botData.noDuplicate)) {
|
|
|
|
throw new Error('Invalid data.bot.noDuplicate data type')
|
2024-06-20 14:46:14 +00:00
|
|
|
}
|
|
|
|
|
2024-06-21 16:18:11 +00:00
|
|
|
// terms not present in livechat <= 10.2.0
|
2024-09-07 12:49:27 +00:00
|
|
|
let terms = data.terms ?? undefined
|
2024-06-21 16:18:11 +00:00
|
|
|
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 }
|
|
|
|
|
2023-09-19 16:56:39 +00:00
|
|
|
const result: ChannelConfigurationOptions = {
|
2023-09-21 17:32:47 +00:00
|
|
|
bot: {
|
|
|
|
enabled: _readBoolean(botData, 'enabled'),
|
|
|
|
nickname: _readSimpleInput(botData, 'nickname', true),
|
|
|
|
forbiddenWords: await _readForbiddenWords(botData),
|
2024-09-06 22:46:23 +00:00
|
|
|
forbidSpecialChars: await _readForbidSpecialChars(botData),
|
2024-09-10 16:59:56 +00:00
|
|
|
noDuplicate: await _readNoDuplicate(botData),
|
2023-09-21 17:32:47 +00:00
|
|
|
quotes: _readQuotes(botData),
|
|
|
|
commands: _readCommands(botData)
|
|
|
|
// TODO: bannedJIDs
|
2024-02-13 11:49:22 +00:00
|
|
|
},
|
|
|
|
slowMode: {
|
2024-03-07 16:56:27 +00:00
|
|
|
duration: _readInteger(slowModeData, 'duration', 0, 1000)
|
2024-06-20 14:46:14 +00:00
|
|
|
},
|
|
|
|
mute: {
|
|
|
|
anonymous: _readBoolean(mute, 'anonymous')
|
2024-07-09 14:15:07 +00:00
|
|
|
},
|
|
|
|
moderation: {
|
2024-07-26 15:04:14 +00:00
|
|
|
delay: _readInteger(moderationData, 'delay', 0, 60),
|
|
|
|
anonymize: _readBoolean(moderationData, 'anonymize')
|
2023-09-21 17:32:47 +00:00
|
|
|
}
|
2023-09-19 16:56:39 +00:00
|
|
|
}
|
2024-06-21 16:18:11 +00:00
|
|
|
if (terms !== undefined) {
|
|
|
|
result.terms = terms
|
|
|
|
}
|
2023-09-19 16:56:39 +00:00
|
|
|
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
function _assertObjectType (data: unknown): data is Record<string, unknown> {
|
|
|
|
return !!data && (typeof data === 'object') && Object.keys(data).every(k => typeof k === 'string')
|
|
|
|
}
|
|
|
|
|
|
|
|
function _readBoolean (data: Record<string, unknown>, f: string): boolean {
|
2023-09-19 16:56:39 +00:00
|
|
|
if (!(f in data)) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
if (typeof data[f] !== 'boolean') {
|
|
|
|
throw new Error('Invalid data type for field ' + f)
|
|
|
|
}
|
|
|
|
return data[f]
|
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
function _readInteger (data: Record<string, unknown>, f: string, min: number, max: number): number {
|
2023-09-21 17:32:47 +00:00
|
|
|
if (!(f in data)) {
|
|
|
|
throw new Error('Missing integer value for field ' + f)
|
|
|
|
}
|
2024-09-07 12:49:27 +00:00
|
|
|
const v = typeof data[f] === 'number' ? Math.trunc(data[f]) : parseInt(data[f] as string)
|
2023-09-21 17:32:47 +00:00
|
|
|
if (isNaN(v)) {
|
|
|
|
throw new Error('Invalid value type for field ' + f)
|
|
|
|
}
|
|
|
|
if (v < min) {
|
|
|
|
throw new Error('Invalid value type (<min) for field ' + f)
|
|
|
|
}
|
|
|
|
if (v > max) {
|
|
|
|
throw new Error('Invalid value type (>max) for field ' + f)
|
|
|
|
}
|
|
|
|
return v
|
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
function _readSimpleInput (data: Record<string, unknown>, f: string, strict?: boolean, noSpace?: boolean): string {
|
2023-09-19 16:56:39 +00:00
|
|
|
if (!(f in data)) {
|
|
|
|
return ''
|
|
|
|
}
|
|
|
|
if (typeof data[f] !== 'string') {
|
|
|
|
throw new Error('Invalid data type for field ' + f)
|
|
|
|
}
|
2023-09-21 17:32:47 +00:00
|
|
|
// Removing control characters.
|
|
|
|
// eslint-disable-next-line no-control-regex
|
2024-09-07 12:49:27 +00:00
|
|
|
let s = data[f].replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
|
2023-09-21 17:32:47 +00:00
|
|
|
if (strict) {
|
|
|
|
// Replacing all invalid characters, no need to throw an error..
|
|
|
|
s = s.replace(/[^\p{L}\p{N}\p{Z}_-]$/gu, '')
|
|
|
|
}
|
2023-09-25 10:51:15 +00:00
|
|
|
if (noSpace) {
|
|
|
|
s = s.replace(/\s+/g, '')
|
|
|
|
}
|
2023-09-21 17:32:47 +00:00
|
|
|
return s
|
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
function _readStringArray (data: Record<string, unknown>, f: string): string[] {
|
2023-09-21 17:32:47 +00:00
|
|
|
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
|
2023-09-19 16:56:39 +00:00
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
function _readMultiLineString (data: Record<string, unknown>, f: string): string {
|
2023-09-25 11:16:15 +00:00
|
|
|
if (!(f in data)) {
|
|
|
|
return ''
|
|
|
|
}
|
|
|
|
if (typeof data[f] !== 'string') {
|
|
|
|
throw new Error('Invalid data type for field ' + f)
|
|
|
|
}
|
2023-09-25 13:37:58 +00:00
|
|
|
// Removing control characters (must authorize \u001A: line feed)
|
2023-09-25 11:16:15 +00:00
|
|
|
// eslint-disable-next-line no-control-regex
|
2024-09-07 12:49:27 +00:00
|
|
|
const s = data[f].replace(/[\u0000-\u0009\u001B-\u001F\u007F-\u009F]/g, '')
|
2023-09-25 11:16:15 +00:00
|
|
|
return s
|
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
async function _readRegExpArray (data: Record<string, unknown>, f: string): Promise<string[]> {
|
2023-09-19 16:56:39 +00:00
|
|
|
// 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 []
|
2023-08-09 14:16:02 +00:00
|
|
|
}
|
2023-09-19 16:56:39 +00:00
|
|
|
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)
|
2023-08-09 14:16:02 +00:00
|
|
|
}
|
2023-09-19 16:56:39 +00:00
|
|
|
if (v === '' || /^\s+$/.test(v)) {
|
|
|
|
// ignore empty values
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
// value must be a valid regexp
|
|
|
|
try {
|
2024-09-07 12:49:27 +00:00
|
|
|
async function _validate (v: string): Promise<void> {
|
2023-08-09 14:16:02 +00:00
|
|
|
// eslint-disable-next-line no-new
|
|
|
|
new RegExp(v)
|
|
|
|
}
|
2024-09-07 12:49:27 +00:00
|
|
|
await _validate(v)
|
2023-09-19 16:56:39 +00:00
|
|
|
} catch (_err) {
|
|
|
|
throw new Error('Invalid value in field ' + f)
|
2023-08-09 14:16:02 +00:00
|
|
|
}
|
2023-09-19 16:56:39 +00:00
|
|
|
result.push(v)
|
2023-08-09 14:16:02 +00:00
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
async function _readForbiddenWords (
|
|
|
|
botData: Record<string, unknown>
|
|
|
|
): Promise<ChannelConfigurationOptions['bot']['forbiddenWords']> {
|
2023-09-21 17:32:47 +00:00
|
|
|
if (!Array.isArray(botData.forbiddenWords)) {
|
|
|
|
throw new Error('Invalid forbiddenWords data')
|
|
|
|
}
|
|
|
|
const result: ChannelConfigurationOptions['bot']['forbiddenWords'] = []
|
|
|
|
for (const fw of botData.forbiddenWords) {
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(fw)) {
|
|
|
|
throw new Error('Invalid entry in botData.forbiddenWords')
|
|
|
|
}
|
2023-09-21 17:32:47 +00:00
|
|
|
const regexp = !!fw.regexp
|
|
|
|
let entries
|
|
|
|
if (regexp) {
|
|
|
|
entries = await _readRegExpArray(fw, 'entries')
|
|
|
|
} else {
|
|
|
|
entries = _readStringArray(fw, 'entries')
|
|
|
|
}
|
|
|
|
const applyToModerators = _readBoolean(fw, 'applyToModerators')
|
2024-04-05 18:46:14 +00:00
|
|
|
const label = fw.label ? _readSimpleInput(fw, 'label') : undefined
|
2023-09-21 17:32:47 +00:00
|
|
|
const reason = fw.reason ? _readSimpleInput(fw, 'reason') : undefined
|
2023-09-25 11:16:15 +00:00
|
|
|
const comments = fw.comments ? _readMultiLineString(fw, 'comments') : undefined
|
2023-09-21 17:32:47 +00:00
|
|
|
|
|
|
|
result.push({
|
|
|
|
regexp,
|
|
|
|
entries,
|
|
|
|
applyToModerators,
|
2024-04-05 18:46:14 +00:00
|
|
|
label,
|
2023-09-25 11:16:15 +00:00
|
|
|
reason,
|
|
|
|
comments
|
2023-09-21 17:32:47 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2024-09-06 22:46:23 +00:00
|
|
|
async function _readForbidSpecialChars (
|
2024-09-07 12:49:27 +00:00
|
|
|
botData: Record<string, unknown>
|
2024-09-06 22:46:23 +00:00
|
|
|
): Promise<ChannelConfigurationOptions['bot']['forbidSpecialChars']> {
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(botData.forbidSpecialChars)) {
|
2024-09-06 22:46:23 +00:00
|
|
|
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, 10),
|
|
|
|
applyToModerators: _readBoolean(botData.forbidSpecialChars, 'applyToModerators')
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2024-09-10 16:59:56 +00:00
|
|
|
async function _readNoDuplicate (
|
|
|
|
botData: Record<string, unknown>
|
|
|
|
): Promise<ChannelConfigurationOptions['bot']['noDuplicate']> {
|
|
|
|
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, 24 * 3600),
|
|
|
|
applyToModerators: _readBoolean(botData.noDuplicate, 'applyToModerators')
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
function _readQuotes (botData: Record<string, unknown>): ChannelConfigurationOptions['bot']['quotes'] {
|
2023-09-21 17:32:47 +00:00
|
|
|
if (!Array.isArray(botData.quotes)) {
|
|
|
|
throw new Error('Invalid quotes data')
|
|
|
|
}
|
|
|
|
const result: ChannelConfigurationOptions['bot']['quotes'] = []
|
2023-09-22 14:30:12 +00:00
|
|
|
for (const qs of botData.quotes) {
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(qs)) {
|
|
|
|
throw new Error('Invalid entry in botData.quotes')
|
|
|
|
}
|
2023-09-22 14:30:12 +00:00
|
|
|
const messages = _readStringArray(qs, 'messages')
|
|
|
|
const delay = _readInteger(qs, 'delay', 1, 6000)
|
2023-09-21 17:32:47 +00:00
|
|
|
|
|
|
|
result.push({
|
|
|
|
messages,
|
|
|
|
delay
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2024-09-07 12:49:27 +00:00
|
|
|
function _readCommands (botData: Record<string, unknown>): ChannelConfigurationOptions['bot']['commands'] {
|
2023-09-21 17:32:47 +00:00
|
|
|
if (!Array.isArray(botData.commands)) {
|
|
|
|
throw new Error('Invalid commands data')
|
|
|
|
}
|
|
|
|
const result: ChannelConfigurationOptions['bot']['commands'] = []
|
2023-09-22 14:30:12 +00:00
|
|
|
for (const cs of botData.commands) {
|
2024-09-07 12:49:27 +00:00
|
|
|
if (!_assertObjectType(cs)) {
|
|
|
|
throw new Error('Invalid entry in botData.commands')
|
|
|
|
}
|
2023-09-22 14:30:12 +00:00
|
|
|
const message = _readSimpleInput(cs, 'message')
|
2023-09-25 10:51:15 +00:00
|
|
|
const command = _readSimpleInput(cs, 'command', false, true)
|
2023-09-21 17:32:47 +00:00
|
|
|
|
|
|
|
result.push({
|
|
|
|
message,
|
|
|
|
command
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2023-08-09 14:16:02 +00:00
|
|
|
export {
|
2023-09-06 13:23:39 +00:00
|
|
|
sanitizeChannelConfigurationOptions
|
2023-08-09 14:16:02 +00:00
|
|
|
}
|