import type { Router, RequestHandler, Request, Response, NextFunction } from 'express' import type { ProxyOptions } from 'express-http-proxy' import type { ChatType, ProsodyListRoomsResult, ProsodyListRoomsResultRoom } from '../../../shared/lib/types' import { getBaseRouterRoute, getBaseStaticRoute, isUserAdmin } from '../helpers' import { asyncMiddleware } from '../middlewares/async' import { getProsodyDomain } from '../prosody/config/domain' import { getAPIKey } from '../apikey' import { getChannelInfosById, getChannelNameById } from '../database/channel' import { isAutoColorsAvailable, areAutoColorsValid, AutoColors } from '../../../shared/lib/autocolors' import * as path from 'path' const bodyParser = require('body-parser') const got = require('got') const fs = require('fs').promises const proxy = require('express-http-proxy') let httpBindRoute: RequestHandler interface ProsodyHttpBindInfo { host: string port: string } let currentProsodyHttpBindInfo: ProsodyHttpBindInfo | null = null async function initWebchatRouter (options: RegisterServerOptions): Promise { const { getRouter, peertubeHelpers, settingsManager } = options const converseJSIndex = await fs.readFile(path.resolve(__dirname, '../../conversejs/index.html')) const router: Router = getRouter() // eslint-disable-next-line @typescript-eslint/no-misused-promises router.get('/room/:roomKey', asyncMiddleware( async (req: Request, res: Response, _next: NextFunction): Promise => { res.removeHeader('X-Frame-Options') // this route can be opened in an iframe const roomKey = req.params.roomKey const settings = await settingsManager.getSettings([ 'chat-type', 'chat-room', 'chat-server', 'chat-bosh-uri', 'chat-ws-uri', 'prosody-room-type', 'converse-theme', 'converse-autocolors' ]) const chatType: ChatType = (settings['chat-type'] ?? 'disabled') as ChatType let jid: string let room: string let boshUri: string let wsUri: string let authenticationUrl: string = '' let advancedControls: boolean = false let forceReadonly: boolean = false let converseJSTheme: string = settings['converse-theme'] as string if (!/^\w+$/.test(converseJSTheme)) { converseJSTheme = 'peertube' } if (chatType === 'builtin-prosody') { const prosodyDomain = await getProsodyDomain(options) jid = 'anon.' + prosodyDomain if (req.query.forcetype === '1') { // We come from the room list in the settings page. // Here we don't read the prosody-room-type settings, // but use the roomKey format. // NB: there is no extra security. Any user can add this parameter. // This is not an issue: the setting will be tested at the room creation. // No room can be created in the wrong mode. if (/^channel\.\d+$/.test(roomKey)) { room = 'channel.{{CHANNEL_ID}}@room.' + prosodyDomain } else { room = '{{VIDEO_UUID}}@room.' + prosodyDomain } } else { if (settings['prosody-room-type'] === 'channel') { room = 'channel.{{CHANNEL_ID}}@room.' + prosodyDomain } else { room = '{{VIDEO_UUID}}@room.' + prosodyDomain } } boshUri = getBaseRouterRoute(options) + 'webchat/http-bind' wsUri = '' authenticationUrl = options.peertubeHelpers.config.getWebserverUrl() + getBaseRouterRoute(options) + 'api/auth' advancedControls = true if (req.query._readonly === 'true') { forceReadonly = true } } else if (chatType === 'builtin-converse') { if (!settings['chat-server']) { throw new Error('Missing chat-server settings.') } if (!settings['chat-room']) { throw new Error('Missing chat-room settings.') } if (!settings['chat-bosh-uri'] && !settings['chat-ws-uri']) { throw new Error('Missing BOSH or Websocket uri.') } jid = settings['chat-server'] as string room = settings['chat-room'] as string boshUri = settings['chat-bosh-uri'] as string wsUri = settings['chat-ws-uri'] as string } else { throw new Error('Builtin chat disabled.') } let video: MVideoThumbnail | undefined let channelId: number const channelMatches = roomKey.match(/^channel\.(\d+)$/) if (channelMatches?.[1]) { channelId = parseInt(channelMatches[1]) // Here we are on a room... must be in prosody mode. if (chatType !== 'builtin-prosody') { throw new Error('Cant access a chat by a channel uri if chatType!==builtin-prosody') } const channelInfos = await getChannelInfosById(options, channelId) if (!channelInfos) { throw new Error('Channel not found') } channelId = channelInfos.id } else { const uuid = roomKey // must be a video UUID. video = await peertubeHelpers.videos.loadByIdOrUUID(uuid) if (!video) { throw new Error('Video not found') } channelId = video.channelId } let page = '' + (converseJSIndex as string) const baseStaticUrl = getBaseStaticRoute(options) page = page.replace(/{{BASE_STATIC_URL}}/g, baseStaticUrl) page = page.replace(/{{JID}}/g, jid) // Computing the room name... if (room.includes('{{VIDEO_UUID}}')) { if (!video) { throw new Error('Missing video') } room = room.replace(/{{VIDEO_UUID}}/g, video.uuid) } room = room.replace(/{{CHANNEL_ID}}/g, `${channelId}`) if (room.includes('{{CHANNEL_NAME}}')) { const channelName = await getChannelNameById(options, channelId) if (channelName === null) { throw new Error('Channel not found') } if (!/^[a-zA-Z0-9_.]+$/.test(channelName)) { // FIXME: see if there is a response here https://github.com/Chocobozzz/PeerTube/issues/4301 for allowed chars peertubeHelpers.logger.error(`Invalid channel name, contains unauthorized chars: '${channelName}'`) throw new Error('Invalid channel name, contains unauthorized chars') } room = room.replace(/{{CHANNEL_NAME}}/g, channelName) } let autocolorsStyles = '' if ( settings['converse-autocolors'] && isAutoColorsAvailable(settings['chat-type'] as ChatType, settings['converse-theme'] as string) ) { peertubeHelpers.logger.debug('Trying to load AutoColors...') const autocolors: AutoColors = { mainForeground: req.query._ac_mainForeground?.toString() ?? '', mainBackground: req.query._ac_mainBackground?.toString() ?? '', greyForeground: req.query._ac_greyForeground?.toString() ?? '', greyBackground: req.query._ac_greyBackground?.toString() ?? '', menuForeground: req.query._ac_menuForeground?.toString() ?? '', menuBackground: req.query._ac_menuBackground?.toString() ?? '', inputForeground: req.query._ac_inputForeground?.toString() ?? '', inputBackground: req.query._ac_inputBackground?.toString() ?? '', buttonForeground: req.query._ac_buttonForeground?.toString() ?? '', buttonBackground: req.query._ac_buttonBackground?.toString() ?? '', link: req.query._ac_link?.toString() ?? '', linkHover: req.query._ac_linkHover?.toString() ?? '' } const autoColorsTest = areAutoColorsValid(autocolors) if (autoColorsTest === true) { autocolorsStyles = ` ` } else { peertubeHelpers.logger.error('Provided AutoColors are invalid.', autoColorsTest) } } else { peertubeHelpers.logger.debug('No AutoColors.') } // ... then inject it in the page. page = page.replace(/{{ROOM}}/g, room) page = page.replace(/{{BOSH_SERVICE_URL}}/g, boshUri) page = page.replace(/{{WS_SERVICE_URL}}/g, wsUri) page = page.replace(/{{AUTHENTICATION_URL}}/g, authenticationUrl) page = page.replace(/{{ADVANCEDCONTROLS}}/g, advancedControls ? 'true' : 'false') page = page.replace(/{{CONVERSEJS_THEME}}/g, converseJSTheme) page = page.replace(/{{CONVERSEJS_AUTOCOLORS}}/g, autocolorsStyles) page = page.replace(/{{FORCEREADONLY}}/g, forceReadonly ? 'true' : 'false') res.status(200) res.type('html') res.send(page) } )) changeHttpBindRoute(options, null) router.all('/http-bind', bodyParser.raw({ type: 'text/xml' }), (req: Request, res: Response, next: NextFunction) => { httpBindRoute(req, res, next) } ) router.get('/prosody-list-rooms', asyncMiddleware( async (req: Request, res: Response, _next: NextFunction) => { if (!res.locals.authenticated) { res.sendStatus(403) return } if (!await isUserAdmin(options, res)) { res.sendStatus(403) return } const chatType: ChatType = await options.settingsManager.getSetting('chat-type') as ChatType if (chatType !== 'builtin-prosody') { const message = 'Please save the settings first.' // TODO: translate? res.status(200) const r: ProsodyListRoomsResult = { ok: false, error: message } res.json(r) return } if (!currentProsodyHttpBindInfo) { throw new Error('It seems that prosody is not binded... Cant list rooms.') } const apiUrl = `http://localhost:${currentProsodyHttpBindInfo.port}/peertubelivechat_list_rooms/list-rooms` peertubeHelpers.logger.debug('Calling list rooms API on url: ' + apiUrl) const rooms = await got(apiUrl, { method: 'GET', headers: { authorization: 'Bearer ' + await getAPIKey(options), host: currentProsodyHttpBindInfo.host }, responseType: 'json', resolveBodyOnly: true }) if (Array.isArray(rooms)) { for (let i = 0; i < rooms.length; i++) { const room: ProsodyListRoomsResultRoom = rooms[i] const matches = room.localpart.match(/^channel\.(\d+)$/) if (matches?.[1]) { const channelId = parseInt(matches[1]) const channelInfos = await getChannelInfosById(options, channelId) if (channelInfos) { room.channel = { id: channelInfos.id, name: channelInfos.name, displayName: channelInfos.displayName } } } } } res.status(200) const r: ProsodyListRoomsResult = { ok: true, rooms: rooms } res.json(r) } )) return router } function changeHttpBindRoute ( { peertubeHelpers }: RegisterServerOptions, prosodyHttpBindInfo: ProsodyHttpBindInfo | null ): void { const logger = peertubeHelpers.logger if (prosodyHttpBindInfo && !/^\d+$/.test(prosodyHttpBindInfo.port)) { logger.error(`Port '${prosodyHttpBindInfo.port}' is not valid. Replacing by null`) prosodyHttpBindInfo = null } if (!prosodyHttpBindInfo) { logger.info('Changing http-bind port for null') currentProsodyHttpBindInfo = null httpBindRoute = (_req: Request, res: Response, _next: NextFunction) => { res.status(404) res.send('Not found') } } else { logger.info('Changing http-bind port for ' + prosodyHttpBindInfo.port + ', on host ' + prosodyHttpBindInfo.host) const options: ProxyOptions = { https: false, proxyReqPathResolver: async (_req: Request): Promise => { return '/http-bind' // should not be able to access anything else }, // preserveHostHdr: true, parseReqBody: true // Note that setting this to false overrides reqAsBuffer and reqBodyEncoding below. // FIXME: should we remove cookies? } currentProsodyHttpBindInfo = prosodyHttpBindInfo httpBindRoute = proxy('http://localhost:' + prosodyHttpBindInfo.port, options) } } export { initWebchatRouter, changeHttpBindRoute }