Channel Configuration UI WIP

This commit is contained in:
John Livingston 2023-09-21 19:32:47 +02:00
parent cc673bd3cb
commit aa71a302f6
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
6 changed files with 296 additions and 53 deletions

View File

@ -47,9 +47,9 @@ async function renderConfigurationChannel (
name="bot" name="bot"
id="peertube-livechat-bot" id="peertube-livechat-bot"
value="1" value="1"
{{#channelConfiguration.configuration.bot}} {{#channelConfiguration.configuration.bot.enabled}}
checked="checked" checked="checked"
{{/channelConfiguration.configuration.bot}} {{/channelConfiguration.configuration.bot.enabled}}
/> />
{{enableBot}} {{enableBot}}
</label> </label>
@ -61,7 +61,7 @@ async function renderConfigurationChannel (
name="bot_nickname" name="bot_nickname"
class="form-control" class="form-control"
id="peertube-livechat-bot-nickname" id="peertube-livechat-bot-nickname"
value="{{channelConfiguration.configuration.botNickname}}" value="{{channelConfiguration.configuration.bot.nickname}}"
/> />
</div> </div>
</div> </div>
@ -84,11 +84,7 @@ async function renderConfigurationChannel (
name="forbidden_words_{{fieldNumber}}" name="forbidden_words_{{fieldNumber}}"
id="peertube-livechat-forbidden-words-{{fieldNumber}}" id="peertube-livechat-forbidden-words-{{fieldNumber}}"
class="form-control" class="form-control"
>{{ >{{entries}}</textarea>
#channelConfiguration.configuration.forbiddenWords
}}{{.}}\n{{
/channelConfiguration.configuration.forbiddenWords
}}</textarea>
<p class="form-group-description">{{forbiddenWordsDesc2}}</p> <p class="form-group-description">{{forbiddenWordsDesc2}}</p>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -97,6 +93,9 @@ async function renderConfigurationChannel (
type="checkbox" type="checkbox"
name="forbidden_words_regexp_{{fieldNumber}}" name="forbidden_words_regexp_{{fieldNumber}}"
value="1" value="1"
{{#regexp}}
checked="checked"
{{/regexp}}
/> />
{{forbiddenWordsRegexp}} {{forbiddenWordsRegexp}}
</label> </label>
@ -108,6 +107,9 @@ async function renderConfigurationChannel (
type="checkbox" type="checkbox"
name="forbidden_words_applytomoderators_{{fieldNumber}}" name="forbidden_words_applytomoderators_{{fieldNumber}}"
value="1" value="1"
{{#applyToModerators}}
checked="checked"
{{/applyToModerators}}
/> />
{{forbiddenWordsApplyToModerators}} {{forbiddenWordsApplyToModerators}}
</label> </label>
@ -120,7 +122,7 @@ async function renderConfigurationChannel (
name="forbidden_words_reason_{{fieldNumber}}" name="forbidden_words_reason_{{fieldNumber}}"
class="form-control" class="form-control"
id="peertube-livechat-forbidden-words-reason-{{fieldNumber}}" id="peertube-livechat-forbidden-words-reason-{{fieldNumber}}"
value="" value="{{reason}}"
/> />
<p class="form-group-description">{{forbiddenWordsReasonDesc}}</p> <p class="form-group-description">{{forbiddenWordsReasonDesc}}</p>
</div> </div>

View File

@ -33,15 +33,23 @@ async function getConfigurationChannelViewData (
throw new Error('Invalid channel configuration options.') 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 { return {
channelConfiguration, channelConfiguration,
forbiddenWordsArray: [0, 1, 2].map(count => { forbiddenWordsArray,
return {
displayNumber: count + 1,
fieldNumber: count,
displayHelp: count === 0
}
}),
quotesArray: [0].map(count => { quotesArray: [0].map(count => {
return { return {
displayNumber: count + 1, displayNumber: count + 1,
@ -89,11 +97,35 @@ async function vivifyConfigurationChannel (
const submitForm: Function = async () => { const submitForm: Function = async () => {
const data = new FormData(form) const data = new FormData(form)
const channelConfigurationOptions: ChannelConfigurationOptions = { const channelConfigurationOptions: ChannelConfigurationOptions = {
bot: data.get('bot') === '1', bot: {
botNickname: data.get('bot_nickname')?.toString() ?? '', enabled: data.get('bot') === '1',
// bannedJIDs: (data.get('banned_jids')?.toString() ?? '').split(/\r?\n|\r|\n/g), nickname: data.get('bot_nickname')?.toString() ?? '',
forbiddenWords: (data.get('forbidden_words')?.toString() ?? '').split(/\r?\n|\r|\n/g) // 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() ?? {} const headers: any = clientOptions.peertubeHelpers.getAuthHeader() ?? {}
headers['content-type'] = 'application/json;charset=UTF-8' headers['content-type'] = 'application/json;charset=UTF-8'

View File

@ -18,11 +18,20 @@ async function sanitizeChannelConfigurationOptions (
throw new Error('Invalid data type') 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 = { const result: ChannelConfigurationOptions = {
bot: _readBoolean(data, 'bot'), bot: {
botNickname: _readSimpleInput(data, 'botNickname'), enabled: _readBoolean(botData, 'enabled'),
// bannedJIDs: await _readRegExpArray(data, 'bannedJIDs'), nickname: _readSimpleInput(botData, 'nickname', true),
forbiddenWords: await _readRegExpArray(data, 'forbiddenWords') forbiddenWords: await _readForbiddenWords(botData),
quotes: _readQuotes(botData),
commands: _readCommands(botData)
// TODO: bannedJIDs
}
} }
return result return result
@ -38,15 +47,59 @@ function _readBoolean (data: any, f: string): boolean {
return data[f] 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 (<min) for field ' + f)
}
if (v > 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)) { if (!(f in data)) {
return '' return ''
} }
if (typeof data[f] !== 'string') { if (typeof data[f] !== 'string') {
throw new Error('Invalid data type for field ' + f) throw new Error('Invalid data type for field ' + f)
} }
// 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.. // Replacing all invalid characters, no need to throw an error..
return (data[f] as string).replace(/[^\p{L}\p{N}\p{Z}_-]$/gu, '') 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<string[]> { async function _readRegExpArray (data: any, f: string): Promise<string[]> {
@ -82,6 +135,66 @@ async function _readRegExpArray (data: any, f: string): Promise<string[]> {
return result return result
} }
async function _readForbiddenWords (botData: any): Promise<ChannelConfigurationOptions['bot']['forbiddenWords']> {
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 { export {
sanitizeChannelConfigurationOptions sanitizeChannelConfigurationOptions
} }

View File

@ -36,10 +36,43 @@ async function getChannelConfigurationOptions (
function getDefaultChannelConfigurationOptions (_options: RegisterServerOptions): ChannelConfigurationOptions { function getDefaultChannelConfigurationOptions (_options: RegisterServerOptions): ChannelConfigurationOptions {
return { return {
bot: false, bot: {
botNickname: 'Sepia', enabled: false,
// bannedJIDs: [], nickname: 'Sepia',
forbiddenWords: [] // 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, // If we want the bot to correctly enable/disable the handlers,
// we must always define all handlers, even if not used. // we must always define all handlers, even if not used.
const handlers: ConfigHandlers = [] const handlers: ConfigHandlers = []
channelConfigurationOptions.bot.forbiddenWords.forEach((v, i) => {
handlers.push(_getForbiddenWordsHandler( handlers.push(_getForbiddenWordsHandler(
'forbidden_words_0', 'forbidden_words_' + i.toString(),
channelConfigurationOptions.forbiddenWords channelConfigurationOptions.bot.forbiddenWords[i]
)) ))
})
const roomConf: ChannelCommonRoomConf = { const roomConf: ChannelCommonRoomConf = {
enabled: channelConfigurationOptions.bot, enabled: channelConfigurationOptions.bot.enabled,
handlers handlers
} }
if (channelConfigurationOptions.botNickname && channelConfigurationOptions.botNickname !== '') { if (channelConfigurationOptions.bot.nickname && channelConfigurationOptions.bot.nickname !== '') {
roomConf.nick = channelConfigurationOptions.botNickname roomConf.nick = channelConfigurationOptions.bot.nickname
} }
return roomConf return roomConf
} }
function _getForbiddenWordsHandler ( function _getForbiddenWordsHandler (
id: string, id: string,
forbiddenWords: string[], forbiddenWords: ChannelConfigurationOptions['bot']['forbiddenWords'][0]
reason?: string
): ConfigHandler { ): ConfigHandler {
const handler: ConfigHandler = { const handler: ConfigHandler = {
type: 'moderate', type: 'moderate',
@ -114,26 +148,63 @@ function _getForbiddenWordsHandler (
rules: [] rules: []
} }
} }
if (forbiddenWords.length === 0) { if (forbiddenWords.entries.length === 0) {
return handler return handler
} }
handler.enabled = true handler.enabled = true
const rule: any = {
name: id
}
if (forbiddenWords.regexp) {
// Note: on the Peertube frontend, channelConfigurationOptions.forbiddenWords // Note: on the Peertube frontend, channelConfigurationOptions.forbiddenWords
// is an array of RegExp definition (strings). // is an array of RegExp definition (strings).
// They are validated one by bone. // They are validated one by bone.
// To increase the bot performance, we will join them all (hopping the bot will optimize them). // To increase the bot performance, we will join them all (hopping the bot will optimize them).
const rule: any = { rule.regexp = '(?:' + forbiddenWords.entries.join(')|(?:') + ')'
name: id, } else {
regexp: '(?:' + forbiddenWords.join(')|(?:') + ')' // 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 (reason) { if (/\w$/.test(s)) {
rule.reason = reason 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.rules.push(rule)
handler.options.applyToModerators = !!forbiddenWords.applyToModerators
return handler 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 ( function _getFilePath (
options: RegisterServerOptions, options: RegisterServerOptions,
channelId: number | string channelId: number | string

View File

@ -55,6 +55,16 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
// Note: the front-end should do some input validation. // Note: the front-end should do some input validation.
// If there is any invalid value, we just return a 400 error. // If there is any invalid value, we just return a 400 error.
// The frontend should have prevented to post invalid data. // 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) channelOptions = await sanitizeChannelConfigurationOptions(options, channelInfos.id, req.body)
} catch (err) { } catch (err) {
logger.warn(err) logger.warn(err)

View File

@ -53,10 +53,25 @@ interface ChannelInfos {
} }
interface ChannelConfigurationOptions { interface ChannelConfigurationOptions {
bot: boolean bot: {
botNickname?: string enabled: boolean
forbiddenWords: string[] 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[] // TODO: bannedJIDs: string[]
}
} }
interface ChannelConfiguration { interface ChannelConfiguration {