From 0987a036a02a9bfbdb1b9328e056671e70400ff3 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Wed, 9 Aug 2023 16:16:02 +0200 Subject: [PATCH] Moderation configuration screen: WIP. --- client/@types/global.d.ts | 1 + client/common/moderation/logic/channel.ts | 61 +++++++++++++- client/common/moderation/register.ts | 2 +- client/common/moderation/templates/channel.ts | 30 ++++--- languages/en.yml | 1 + server/lib/middlewares/moderation/channel.ts | 53 ++++++++++++ server/lib/moderation/channel/sanitize.ts | 62 ++++++++++++++ server/lib/moderation/channel/storage.ts | 28 +++++++ server/lib/routers/api/moderation.ts | 81 +++++++++---------- shared/lib/types.ts | 20 +++-- 10 files changed, 275 insertions(+), 64 deletions(-) create mode 100644 server/lib/middlewares/moderation/channel.ts create mode 100644 server/lib/moderation/channel/sanitize.ts create mode 100644 server/lib/moderation/channel/storage.ts diff --git a/client/@types/global.d.ts b/client/@types/global.d.ts index 348caaf7..d00586ef 100644 --- a/client/@types/global.d.ts +++ b/client/@types/global.d.ts @@ -33,6 +33,7 @@ declare const LOC_CONNECT_USING_XMPP_HELP: string declare const LOC_SAVE: string declare const LOC_CANCEL: string +declare const LOC_SUCCESSFULLY_SAVED: string declare const LOC_MENU_MODERATION_LABEL: string declare const LOC_LIVECHAT_MODERATION_TITLE: string declare const LOC_LIVECHAT_MODERATION_DESC: string diff --git a/client/common/moderation/logic/channel.ts b/client/common/moderation/logic/channel.ts index ca14e1d0..6fe3dbaa 100644 --- a/client/common/moderation/logic/channel.ts +++ b/client/common/moderation/logic/channel.ts @@ -1,4 +1,6 @@ import type { RegisterClientOptions } from '@peertube/peertube-types/client' +import type { ChannelModerationOptions } from 'shared/lib/types' +import { getBaseRoute } from '../../../videowatch/uri' /** * Adds the front-end logic on the generated html for the channel moderation options. @@ -7,10 +9,13 @@ import type { RegisterClientOptions } from '@peertube/peertube-types/client' */ async function vivifyModerationChannel ( clientOptions: RegisterClientOptions, - rootEl: HTMLElement + rootEl: HTMLElement, + channelId: string ): Promise { const form = rootEl.querySelector('form[livechat-moderation-channel-options]') as HTMLFormElement if (!form) { return } + const labelSaved = await clientOptions.peertubeHelpers.translate(LOC_SUCCESSFULLY_SAVED) + const labelError = await clientOptions.peertubeHelpers.translate(LOC_ERROR) const enableBotCB = form.querySelector('input[name=bot]') as HTMLInputElement const botEnabledEl = form.querySelector('[livechat-moderation-channel-options-bot-enabled]') as HTMLElement @@ -18,9 +23,59 @@ async function vivifyModerationChannel ( botEnabledEl.style.display = enableBotCB.checked ? 'initial' : 'none' } + const submitForm: Function = async () => { + const data = new FormData(form) + const channelModerationOptions: ChannelModerationOptions = { + bot: data.get('bot') === '1', + bannedJIDs: (data.get('banned_jids')?.toString() ?? '').split(/\r?\n|\r|\n/g), + forbiddenWords: (data.get('forbidden_words')?.toString() ?? '').split(/\r?\n|\r|\n/g) + } + + const headers: any = clientOptions.peertubeHelpers.getAuthHeader() ?? {} + headers['content-type'] = 'application/json;charset=UTF-8' + + const response = await fetch( + getBaseRoute(clientOptions) + '/api/moderation/channel/' + encodeURIComponent(channelId), + { + method: 'POST', + headers, + body: JSON.stringify(channelModerationOptions) + } + ) + + if (!response.ok) { + throw new Error('Failed to save moderation options.') + } + } + const toggleSubmit: Function = (disabled: boolean) => { + form.querySelectorAll('input[type=submit], input[type=reset]').forEach((el) => { + if (disabled) { + el.setAttribute('disabled', 'disabled') + } else { + el.removeAttribute('disabled') + } + }) + } + enableBotCB.onclick = () => refresh() - form.onsubmit = () => false - form.onreset = () => refresh() + form.onsubmit = () => { + toggleSubmit(true) + submitForm().then( + () => { + clientOptions.peertubeHelpers.notifier.success(labelSaved) + toggleSubmit(false) + }, + () => { + clientOptions.peertubeHelpers.notifier.error(labelError) + toggleSubmit(false) + } + ) + return false + } + form.onreset = () => { + // Must refresh in a setTimeout, otherwise the checkbox state is not up to date. + setTimeout(() => refresh(), 1) + } refresh() } diff --git a/client/common/moderation/register.ts b/client/common/moderation/register.ts index 689b9874..9c6fea32 100644 --- a/client/common/moderation/register.ts +++ b/client/common/moderation/register.ts @@ -29,7 +29,7 @@ async function registerModeration (clientOptions: RegisterClientOptions): Promis return } rootEl.innerHTML = html - await vivifyModerationChannel(clientOptions, rootEl) + await vivifyModerationChannel(clientOptions, rootEl, channelId) } }) diff --git a/client/common/moderation/templates/channel.ts b/client/common/moderation/templates/channel.ts index 2d8db176..5b60e45f 100644 --- a/client/common/moderation/templates/channel.ts +++ b/client/common/moderation/templates/channel.ts @@ -1,5 +1,5 @@ import type { RegisterClientOptions } from '@peertube/peertube-types/client' -import type { ChannelModerationOptions } from 'shared/lib/types' +import type { ChannelModeration } from 'shared/lib/types' import { getBaseRoute } from '../../../videowatch/uri' // Must use require for mustache, import seems buggy. const Mustache = require('mustache') @@ -21,18 +21,22 @@ async function renderModerationChannel ( throw new Error('Missing or invalid channel id.') } - const channelModerationOptions: ChannelModerationOptions = await (await fetch( + const response = await fetch( getBaseRoute(registerClientOptions) + '/api/moderation/channel/' + encodeURIComponent(channelId), { method: 'GET', headers: peertubeHelpers.getAuthHeader() } - )).json() - - // Basic testing that channelModerationOptions has the correct format - if ((typeof channelModerationOptions !== 'object') || !channelModerationOptions.channel) { + ) + if (!response.ok) { throw new Error('Can\'t get channel moderation options.') } + const channelModeration: ChannelModeration = await (response).json() + + // Basic testing that channelModeration has the correct format + if ((typeof channelModeration !== 'object') || !channelModeration.channel) { + throw new Error('Invalid channel moderation options.') + } const view = { title: await peertubeHelpers.translate(LOC_LIVECHAT_MODERATION_CHANNEL_TITLE), @@ -43,12 +47,12 @@ async function renderModerationChannel ( bannedJIDs: await peertubeHelpers.translate(LOC_LIVECHAT_MODERATION_CHANNEL_BANNED_JIDS_LABEL), save: await peertubeHelpers.translate(LOC_SAVE), cancel: await peertubeHelpers.translate(LOC_CANCEL), - channelModerationOptions + channelModeration } return Mustache.render(`
-

{{title}} {{channelModerationOptions.channel.displayName}}

+

{{title}} {{channelModeration.moderation.channel.displayName}}

{{description}}

@@ -56,7 +60,7 @@ async function renderModerationChannel ( {{enableBot}} @@ -66,15 +70,15 @@ async function renderModerationChannel (
diff --git a/languages/en.yml b/languages/en.yml index 22496bcd..eb05735c 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -281,6 +281,7 @@ prosody_components_list_description: | save: "Save" cancel: "Cancel" +successfully_saved: "Successfully saved" menu_moderation_label: "Chatrooms" livechat_moderation_title: "Configure your live's chatrooms moderation policies" livechat_moderation_desc: "Here you can configure some advanced options for chatrooms associated to your live streams." diff --git a/server/lib/middlewares/moderation/channel.ts b/server/lib/middlewares/moderation/channel.ts new file mode 100644 index 00000000..20cac27e --- /dev/null +++ b/server/lib/middlewares/moderation/channel.ts @@ -0,0 +1,53 @@ +import type { RegisterServerOptions } from '@peertube/peertube-types' +import type { Request, Response, NextFunction } from 'express' +import type { RequestPromiseHandler } from '../async' +import { getChannelInfosById } from '../../database/channel' +import { isUserAdminOrModerator } from '../../helpers' + +/** + * Returns a middleware handler to get the channelInfos from the channel parameter. + * This is used in api related to channel moderation options. + * @param options Peertube server options + * @returns middleware function + */ +function getCheckModerationChannelMiddleware (options: RegisterServerOptions): RequestPromiseHandler { + return async (req: Request, res: Response, next: NextFunction) => { + const logger = options.peertubeHelpers.logger + const channelId = req.params.channelId + const currentUser = await options.peertubeHelpers.user.getAuthUser(res) + + if (!channelId || !/^\d+$/.test(channelId)) { + res.sendStatus(400) + return + } + + const channelInfos = await getChannelInfosById(options, parseInt(channelId), true) + if (!channelInfos) { + logger.warn(`Channel ${channelId} not found`) + res.sendStatus(404) + return + } + + // To access this page, you must either be: + // - the channel owner, + // - an instance modo/admin + // - TODO: a channel chat moderator, as defined in this page. + if (channelInfos.ownerAccountId === currentUser.Account.id) { + logger.debug('Current user is the channel owner') + } else if (await isUserAdminOrModerator(options, res)) { + logger.debug('Current user is an instance moderator or admin') + } else { + logger.warn('Current user tries to access a channel for which he has no right.') + res.sendStatus(403) + return + } + + logger.debug('User can access the moderation channel api.') + res.locals.channelInfos = channelInfos + next() + } +} + +export { + getCheckModerationChannelMiddleware +} diff --git a/server/lib/moderation/channel/sanitize.ts b/server/lib/moderation/channel/sanitize.ts new file mode 100644 index 00000000..699b81bf --- /dev/null +++ b/server/lib/moderation/channel/sanitize.ts @@ -0,0 +1,62 @@ +import type { RegisterServerOptions } from '@peertube/peertube-types' +import type { ChannelModerationOptions, ChannelInfos } from '../../../../shared/lib/types' + +/** + * Sanitize data so that they can safely be used/stored for channel moderation 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 + */ +async function sanitizeChannelModerationOptions ( + _options: RegisterServerOptions, + _channelInfos: ChannelInfos, + data: any +): Promise { + const result = { + bot: false, + bannedJIDs: [], + forbiddenWords: [] + } + + if (typeof data !== 'object') { + throw new Error('Invalid data type') + } + // boolean fields + for (const f of ['bot']) { + if (!(f in data) || (typeof data[f] !== 'boolean')) { + throw new Error('Invalid data type for field ' + f) + } + result[f as keyof ChannelModerationOptions] = data[f] + } + // value/regexp array fields + for (const f of ['bannedJIDs', 'forbiddenWords']) { + if (!(f in data) || !Array.isArray(data[f])) { + throw new Error('Invalid data type for field ' + f) + } + 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 regexp + try { + // eslint-disable-next-line no-new + new RegExp(v) + } catch (_err) { + throw new Error('Invalid value in field ' + f) + } + (result[f as keyof ChannelModerationOptions] as string[]).push(v) + } + } + + return result +} + +export { + sanitizeChannelModerationOptions +} diff --git a/server/lib/moderation/channel/storage.ts b/server/lib/moderation/channel/storage.ts new file mode 100644 index 00000000..33eea5ed --- /dev/null +++ b/server/lib/moderation/channel/storage.ts @@ -0,0 +1,28 @@ +import type { RegisterServerOptions } from '@peertube/peertube-types' +import type { ChannelModeration, ChannelInfos } from '../../../../shared/lib/types' + +async function getChannelModerationOptions ( + options: RegisterServerOptions, + channelInfos: ChannelInfos +): Promise { + return { + channel: channelInfos, + moderation: { + bot: false, + bannedJIDs: [], + forbiddenWords: [] + } + } +} + +async function storeChannelModerationOptions ( + _options: RegisterServerOptions, + _channelModeration: ChannelModeration +): Promise { + throw new Error('Not implemented yet') +} + +export { + getChannelModerationOptions, + storeChannelModerationOptions +} diff --git a/server/lib/routers/api/moderation.ts b/server/lib/routers/api/moderation.ts index 1061234e..050a16e2 100644 --- a/server/lib/routers/api/moderation.ts +++ b/server/lib/routers/api/moderation.ts @@ -1,61 +1,60 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' import type { Router, Request, Response, NextFunction } from 'express' -import type { ChannelModerationOptions } from '../../../../shared/lib/types' +import type { ChannelInfos } from '../../../../shared/lib/types' import { asyncMiddleware } from '../../middlewares/async' -import { getChannelInfosById } from '../../database/channel' -import { isUserAdminOrModerator } from '../../helpers' +import { getCheckModerationChannelMiddleware } from '../../middlewares/moderation/channel' +import { getChannelModerationOptions, storeChannelModerationOptions } from '../../moderation/channel/storage' +import { sanitizeChannelModerationOptions } from '../../moderation/channel/sanitize' async function initModerationApiRouter (options: RegisterServerOptions): Promise { const router = options.getRouter() const logger = options.peertubeHelpers.logger - router.get('/channel/:channelId', asyncMiddleware( + router.get('/channel/:channelId', asyncMiddleware([ + getCheckModerationChannelMiddleware(options), async (req: Request, res: Response, _next: NextFunction): Promise => { - const channelId = req.params.channelId - const currentUser = await options.peertubeHelpers.user.getAuthUser(res) + 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 - if (!channelId || !/^\d+$/.test(channelId)) { + const result = await getChannelModerationOptions(options, channelInfos) + res.status(200) + res.json(result) + } + ])) + + router.post('/channel/:channelId', asyncMiddleware([ + getCheckModerationChannelMiddleware(options), + async (req: Request, res: Response, _next: NextFunction): Promise => { + 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 ChannelModerationOptions') + + let moderation + try { + moderation = await sanitizeChannelModerationOptions(options, channelInfos, req.body) + } catch (err) { + logger.warn(err) res.sendStatus(400) return } - const channelInfos = await getChannelInfosById(options, parseInt(channelId), true) - if (!channelInfos) { - logger.warn(`Channel ${channelId} not found`) - res.sendStatus(404) - return - } - - // To access this page, you must either be: - // - the channel owner, - // - an instance modo/admin - // - TODO: a channel chat moderator, as defined in this page. - if (channelInfos.ownerAccountId === currentUser.Account.id) { - logger.debug('Current user is the channel owner') - } else if (await isUserAdminOrModerator(options, res)) { - logger.debug('Current user is an instance moderator or admin') - } else { - logger.warn('Current user tries to access a channel for which he has no right.') - res.sendStatus(403) - return - } - - logger.debug('User can access the moderation channel api.') - - const result: ChannelModerationOptions = { - channel: { - id: channelInfos.id, - name: channelInfos.name, - displayName: channelInfos.displayName - }, - bot: false, - forbiddenWords: [], - bannedJIDs: [] - } + logger.debug('Data seems ok, storing them.') + const result = await storeChannelModerationOptions(options, { + channel: channelInfos, + moderation + }) res.status(200) res.json(result) } - )) + ])) return router } diff --git a/shared/lib/types.ts b/shared/lib/types.ts index bbe9b1b8..14bfb888 100644 --- a/shared/lib/types.ts +++ b/shared/lib/types.ts @@ -46,21 +46,29 @@ interface ProsodyListRoomsResultSuccess { type ProsodyListRoomsResult = ProsodyListRoomsResultError | ProsodyListRoomsResultSuccess +interface ChannelInfos { + id: number + name: string + displayName: string +} + interface ChannelModerationOptions { - channel: { - id: number - name: string - displayName: string - } bot: boolean forbiddenWords: string[] bannedJIDs: string[] } +interface ChannelModeration { + channel: ChannelInfos + moderation: ChannelModerationOptions +} + export type { ConverseJSTheme, InitConverseJSParams, ProsodyListRoomsResult, ProsodyListRoomsResultRoom, - ChannelModerationOptions + ChannelInfos, + ChannelModerationOptions, + ChannelModeration }