**Breaking changes** The livechat v13 introduced a new library to handle regular expressions in forbidden words, to avoid [ReDOS](https://en.wikipedia.org/wiki/ReDoS) attacks. Unfortunately, this library was not able to install itself properly on some systems, and some admins were not able to install the livechat plugin. That's why we have disabled this library in v14, and introduce a new settings to enable regexp in forbidden words. By default this settings is disabled, and your users won't be able to use regexp in their forbidden words. The risk by enabling this feature is that a malicious user could cause a denial of service for the chat bot, by using a special crafted regular expression in their channel options, and sending a special crafter message in one of their rooms. If you trust your users (those who have rights to livestream), you can enable the settings. Otherwise it is not recommanded. See the documentation for more informations. **Minor changes and fixes** * Channel's forbidden words: new "enable" column. * New settings to enable regular expressions for channel forbidden words. * "Channel advanced configuration" settings: removing the "experimental feature" label.
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
// SPDX-FileCopyrightText: 2024-2025 John Livingston <https://www.john-livingston.fr/>
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { RegisterServerOptions } from '@peertube/peertube-types'
|
|
import type { Router, Request, Response, NextFunction } from 'express'
|
|
import type { ChannelConfiguration, ChannelEmojisConfiguration, ChannelInfos } from '../../../../shared/lib/types'
|
|
import { asyncMiddleware } from '../../middlewares/async'
|
|
import { getCheckConfigurationChannelMiddleware } from '../../middlewares/configuration/channel'
|
|
import { checkConfigurationEnabledMiddleware } from '../../middlewares/configuration/configuration'
|
|
import {
|
|
getChannelConfigurationOptions,
|
|
getDefaultChannelConfigurationOptions,
|
|
storeChannelConfigurationOptions
|
|
} from '../../configuration/channel/storage'
|
|
import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize'
|
|
import { getConverseJSParams } from '../../../lib/conversejs/params'
|
|
import { Emojis } from '../../../lib/emojis'
|
|
import { RoomChannel } from '../../../lib/room-channel'
|
|
import { updateProsodyRoom } from '../../../lib/prosody/api/manage-rooms'
|
|
|
|
async function initConfigurationApiRouter (options: RegisterServerOptions, router: Router): Promise<void> {
|
|
const logger = options.peertubeHelpers.logger
|
|
|
|
router.get('/configuration/room/:roomKey', asyncMiddleware(
|
|
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
|
const roomKey = req.params.roomKey
|
|
|
|
const user = await options.peertubeHelpers.user.getAuthUser(res)
|
|
|
|
const initConverseJSParam = await getConverseJSParams(
|
|
options,
|
|
roomKey,
|
|
{
|
|
forcetype: req.query.forcetype === '1'
|
|
},
|
|
!!user
|
|
)
|
|
if (('isError' in initConverseJSParam) && initConverseJSParam.isError) {
|
|
res.sendStatus(initConverseJSParam.code)
|
|
return
|
|
}
|
|
res.status(200)
|
|
res.json(initConverseJSParam)
|
|
}
|
|
))
|
|
|
|
router.get('/configuration/channel/:channelId', asyncMiddleware([
|
|
checkConfigurationEnabledMiddleware(options),
|
|
getCheckConfigurationChannelMiddleware(options),
|
|
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
|
if (!res.locals.channelInfos) {
|
|
logger.error('Missing channelInfos in res.locals, should not happen')
|
|
res.sendStatus(500)
|
|
return
|
|
}
|
|
const channelInfos = res.locals.channelInfos as ChannelInfos
|
|
|
|
const channelOptions =
|
|
await getChannelConfigurationOptions(options, channelInfos.id) ??
|
|
getDefaultChannelConfigurationOptions(options)
|
|
|
|
const result: ChannelConfiguration = {
|
|
channel: channelInfos,
|
|
configuration: channelOptions
|
|
}
|
|
res.status(200)
|
|
res.json(result)
|
|
}
|
|
]))
|
|
|
|
router.post('/configuration/channel/:channelId', asyncMiddleware([
|
|
checkConfigurationEnabledMiddleware(options),
|
|
getCheckConfigurationChannelMiddleware(options),
|
|
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
|
if (!res.locals.channelInfos) {
|
|
logger.error('Missing channelInfos in res.locals, should not happen')
|
|
res.sendStatus(500)
|
|
return
|
|
}
|
|
const channelInfos = res.locals.channelInfos as ChannelInfos
|
|
logger.debug('Trying to save ChannelConfigurationOptions')
|
|
|
|
let channelOptions
|
|
try {
|
|
// 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.enabled = false
|
|
}
|
|
// TODO: Same for forbidSpecialChars/noDuplicate: if disabled, don't save reason and tolerance
|
|
// (disabling for now, because it is not acceptable to load twice the channel configuration.
|
|
// Must find better way)
|
|
// if (req.body.bot?.enabled === true && req.body.bot.forbidSpecialChars?.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.forbidSpecialChars.reason = channelOptions.bot.forbidSpecialChars.reason
|
|
// req.body.bot.forbidSpecialChars.tolerance = channelOptions.bot.forbidSpecialChars.tolerance
|
|
// req.body.bot.forbidSpecialChars.applyToModerators = channelOptions.bot.forbidSpecialChars.applyToModerators
|
|
// req.body.bot.forbidSpecialChars.enabled = false
|
|
// ... NoDuplicate...
|
|
// }
|
|
channelOptions = await sanitizeChannelConfigurationOptions(options, channelInfos.id, req.body)
|
|
} catch (err: any) {
|
|
logger.warn(err.message as string)
|
|
if (err.validationErrorMessage && (typeof err.validationErrorMessage === 'string')) {
|
|
res.status(400)
|
|
res.json({
|
|
validationErrorMessage: err.validationErrorMessage
|
|
})
|
|
} else {
|
|
res.sendStatus(400)
|
|
}
|
|
return
|
|
}
|
|
|
|
logger.debug('Data seems ok, storing them.')
|
|
const result: ChannelConfiguration = {
|
|
channel: channelInfos,
|
|
configuration: channelOptions
|
|
}
|
|
await storeChannelConfigurationOptions(options, channelInfos.id, channelOptions)
|
|
res.status(200)
|
|
res.json(result)
|
|
}
|
|
]))
|
|
|
|
router.get('/configuration/channel/emojis/:channelId', asyncMiddleware([
|
|
checkConfigurationEnabledMiddleware(options),
|
|
getCheckConfigurationChannelMiddleware(options),
|
|
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
|
try {
|
|
if (!res.locals.channelInfos) {
|
|
throw new Error('Missing channelInfos in res.locals, should not happen')
|
|
}
|
|
|
|
const emojis = Emojis.singleton()
|
|
const channelInfos = res.locals.channelInfos as ChannelInfos
|
|
|
|
const channelEmojis =
|
|
(await emojis.channelCustomEmojisDefinition(channelInfos.id)) ??
|
|
emojis.emptyChannelDefinition()
|
|
|
|
const result: ChannelEmojisConfiguration = {
|
|
channel: channelInfos,
|
|
emojis: channelEmojis
|
|
}
|
|
res.status(200)
|
|
res.json(result)
|
|
} catch (err) {
|
|
logger.error(err)
|
|
res.sendStatus(500)
|
|
}
|
|
}
|
|
]))
|
|
|
|
router.post('/configuration/channel/emojis/:channelId', asyncMiddleware([
|
|
checkConfigurationEnabledMiddleware(options),
|
|
getCheckConfigurationChannelMiddleware(options),
|
|
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
|
try {
|
|
if (!res.locals.channelInfos) {
|
|
throw new Error('Missing channelInfos in res.locals, should not happen')
|
|
}
|
|
|
|
const emojis = Emojis.singleton()
|
|
const channelInfos = res.locals.channelInfos as ChannelInfos
|
|
|
|
const emojisDefinition = req.body
|
|
let emojisDefinitionSanitized, bufferInfos
|
|
try {
|
|
[emojisDefinitionSanitized, bufferInfos] = await emojis.sanitizeChannelDefinition(
|
|
channelInfos.id,
|
|
emojisDefinition
|
|
)
|
|
} catch (err) {
|
|
logger.warn(err)
|
|
res.sendStatus(400)
|
|
return
|
|
}
|
|
|
|
await emojis.saveChannelDefinition(channelInfos.id, emojisDefinitionSanitized, bufferInfos)
|
|
|
|
// We must update the emoji only regexp on the Prosody server.
|
|
const customEmojisRegexp = await emojis.getChannelCustomEmojisRegexp(channelInfos.id)
|
|
const roomJIDs = RoomChannel.singleton().getChannelRoomJIDs(channelInfos.id)
|
|
for (const roomJID of roomJIDs) {
|
|
// No need to await here
|
|
logger.info(`Updating room ${roomJID} emoji only regexp...`)
|
|
updateProsodyRoom(options, roomJID, {
|
|
livechat_custom_emoji_regexp: customEmojisRegexp
|
|
}).then(
|
|
() => {},
|
|
(err) => logger.error(err)
|
|
)
|
|
}
|
|
|
|
// Reloading data, to send them back to front:
|
|
const channelEmojis =
|
|
(await emojis.channelCustomEmojisDefinition(channelInfos.id)) ??
|
|
emojis.emptyChannelDefinition()
|
|
const result: ChannelEmojisConfiguration = {
|
|
channel: channelInfos,
|
|
emojis: channelEmojis
|
|
}
|
|
res.status(200)
|
|
res.json(result)
|
|
} catch (err) {
|
|
logger.error(err)
|
|
res.sendStatus(500)
|
|
}
|
|
}
|
|
]))
|
|
|
|
router.post('/configuration/channel/emojis/:channelId/enable_emoji_only', asyncMiddleware([
|
|
checkConfigurationEnabledMiddleware(options),
|
|
getCheckConfigurationChannelMiddleware(options),
|
|
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
|
try {
|
|
if (!res.locals.channelInfos) {
|
|
throw new Error('Missing channelInfos in res.locals, should not happen')
|
|
}
|
|
|
|
const emojis = Emojis.singleton()
|
|
const channelInfos = res.locals.channelInfos as ChannelInfos
|
|
|
|
logger.info(`Enabling emoji only mode on each channel ${channelInfos.id} rooms ...`)
|
|
|
|
// We can also update the EmojisRegexp, just in case.
|
|
const customEmojisRegexp = await emojis.getChannelCustomEmojisRegexp(channelInfos.id)
|
|
const roomJIDs = RoomChannel.singleton().getChannelRoomJIDs(channelInfos.id)
|
|
for (const roomJID of roomJIDs) {
|
|
// No need to await here
|
|
logger.info(`Enabling emoji only mode on room ${roomJID} ...`)
|
|
updateProsodyRoom(options, roomJID, {
|
|
livechat_emoji_only: true,
|
|
livechat_custom_emoji_regexp: customEmojisRegexp
|
|
}).then(
|
|
() => {},
|
|
(err) => logger.error(err)
|
|
)
|
|
}
|
|
|
|
res.status(200)
|
|
res.json({ ok: true })
|
|
} catch (err) {
|
|
logger.error(err)
|
|
res.sendStatus(500)
|
|
}
|
|
}
|
|
]))
|
|
}
|
|
|
|
export {
|
|
initConfigurationApiRouter
|
|
}
|