// SPDX-FileCopyrightText: 2024 John Livingston
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterServerOptions, MVideoThumbnail, SettingEntries } from '@peertube/peertube-types'
import type { ConverseJSTheme, InitConverseJSParams, InitConverseJSParamsError } from '../../../shared/lib/types'
import type { RegisterServerOptionsV5 } from '../helpers'
import type { LiveChatJSONLDAttributeV1 } from '../federation/types'
import { getChannelInfosById, getChannelNameById } from '../database/channel'
import {
anonymousConnectionInfos, compatibleRemoteAuthenticatedConnectionEnabled
} from '../federation/connection-infos'
import { getVideoLiveChatInfos } from '../federation/storage'
import { getBaseRouterRoute, getBaseStaticRoute } from '../helpers'
import { getProsodyDomain } from '../prosody/config/domain'
import { getBoshUri, getWSUri } from '../uri/webchat'
import { ExternalAuthOIDC } from '../external-auth/oidc'
import { Emojis } from '../emojis'
interface GetConverseJSParamsParams {
readonly?: boolean | 'noscroll'
transparent?: boolean
forcetype?: boolean
forceDefaultHideMucParticipants?: boolean
}
/**
* Returns ConverseJS options for a given chat room.
* Returns an object describing the error if access can not be granted.
* @param options server options
* @param roomKey chat room key: video UUID (or channel id when forcetype is true)
* @param params various parameters
* @param userIsConnected true if user is connected. If undefined, bypass access tests.
*/
async function getConverseJSParams (
options: RegisterServerOptionsV5,
roomKey: string,
params: GetConverseJSParamsParams,
userIsConnected?: boolean
): Promise {
const settings = await options.settingsManager.getSettings([
'prosody-room-type',
'disable-websocket',
'converse-theme',
'federation-no-remote-chat',
'prosody-room-allow-s2s',
'chat-no-anonymous',
'disable-channel-configuration'
])
if (settings['chat-no-anonymous'] && userIsConnected === false) {
return {
isError: true,
code: 403,
message: 'You must be connected'
}
}
const {
autoViewerMode, forceReadonly, transparent, converseJSTheme
} = _interfaceParams(options, settings, params)
const staticBaseUrl = getBaseStaticRoute(options)
const authenticationUrl = options.peertubeHelpers.config.getWebserverUrl() +
getBaseRouterRoute(options) +
'api/auth'
const roomInfos = await _readRoomKey(options, settings, roomKey)
if ('isError' in roomInfos) {
return roomInfos // is an InitConverseJSParamsError
}
const connectionInfos = await _connectionInfos(options, settings, params, roomInfos)
if ('isError' in connectionInfos) {
return connectionInfos // is an InitConverseJSParamsError
}
const {
localAnonymousJID,
localBoshUri,
localWsUri,
remoteConnectionInfos,
roomJID
} = connectionInfos
let externalAuthOIDC
if (userIsConnected !== true) {
if (remoteConnectionInfos && !remoteConnectionInfos.externalAuthCompatible) {
options.peertubeHelpers.logger.debug(
'The remote livechat plugin is not compatible with external authentication, not enabling the feature'
)
} else {
try {
const oidcs = ExternalAuthOIDC.allSingletons()
for (const oidc of oidcs) {
if (await oidc.isOk()) {
const authUrl = oidc.getConnectUrl()
const buttonLabel = oidc.getButtonLabel()
if (authUrl && buttonLabel) {
externalAuthOIDC ??= []
externalAuthOIDC.push({
type: oidc.type,
buttonLabel: buttonLabel,
url: authUrl
})
}
}
}
} catch (err) {
options.peertubeHelpers.logger.error(err)
}
}
}
return {
peertubeVideoOriginalUrl: roomInfos.video?.url,
peertubeVideoUUID: roomInfos.video?.uuid,
staticBaseUrl,
assetsPath: staticBaseUrl + 'conversejs/',
isRemoteChat: !!(roomInfos.video?.remote),
localAnonymousJID: !settings['chat-no-anonymous'] ? localAnonymousJID : null,
remoteAnonymousJID: remoteConnectionInfos?.anonymous?.userJID ?? null,
remoteAnonymousXMPPServer: !!(remoteConnectionInfos?.anonymous),
remoteAuthenticatedXMPPServer: !!(remoteConnectionInfos?.authenticated),
room: roomJID,
localBoshServiceUrl: localBoshUri,
localWebsocketServiceUrl: localWsUri,
remoteBoshServiceUrl: remoteConnectionInfos?.anonymous?.boshUri ?? null,
remoteWebsocketServiceUrl: remoteConnectionInfos?.anonymous?.wsUri ?? null,
authenticationUrl: authenticationUrl,
autoViewerMode,
theme: converseJSTheme,
forceReadonly,
transparent,
// forceDefaultHideMucParticipants is for testing purpose
// (so we can stress test with the muc participant list hidden by default)
forceDefaultHideMucParticipants: params.forceDefaultHideMucParticipants,
externalAuthOIDC,
customEmojisUrl: connectionInfos.customEmojisUrl
}
}
function _interfaceParams (
options: RegisterServerOptions,
settings: SettingEntries,
params: GetConverseJSParamsParams
): {
autoViewerMode: InitConverseJSParams['autoViewerMode']
forceReadonly: InitConverseJSParams['forceReadonly']
transparent: InitConverseJSParams['transparent']
converseJSTheme: InitConverseJSParams['theme']
} {
let autoViewerMode: boolean = false
const forceReadonly: boolean | 'noscroll' = params.readonly ?? false
if (!forceReadonly) {
autoViewerMode = true // auto join the chat in viewer mode, if not logged in
}
let converseJSTheme: ConverseJSTheme = settings['converse-theme'] as ConverseJSTheme
const transparent: boolean = params.transparent ?? false
if (!/^\w+$/.test(converseJSTheme)) {
converseJSTheme = 'peertube'
}
return {
autoViewerMode,
forceReadonly,
transparent,
converseJSTheme
}
}
interface RoomInfos {
video: MVideoThumbnail | undefined
channelId: number
remoteChatInfos: LiveChatJSONLDAttributeV1 | undefined
roomKey: string
}
async function _readRoomKey (
options: RegisterServerOptions,
settings: SettingEntries,
roomKey: string
): Promise {
let video: MVideoThumbnail | undefined
let channelId: number
let remoteChatInfos: LiveChatJSONLDAttributeV1 | undefined
const channelMatches = roomKey.match(/^channel\.(\d+)$/)
if (channelMatches?.[1]) {
channelId = parseInt(channelMatches[1])
// Here we are on a channel room...
const channelInfos = await getChannelInfosById(options, channelId)
if (!channelInfos) {
return {
isError: true,
code: 404,
message: 'Channel Not Found'
}
}
channelId = channelInfos.id
} else {
const uuid = roomKey // must be a video UUID.
video = await options.peertubeHelpers.videos.loadByIdOrUUID(uuid)
if (!video) {
return {
isError: true,
code: 404,
message: 'Not Found'
}
}
if (video.remote) {
remoteChatInfos = settings['federation-no-remote-chat'] ? false : await getVideoLiveChatInfos(options, video)
if (!remoteChatInfos) {
return {
isError: true,
code: 404,
message: 'Not Found'
}
}
}
channelId = video.channelId
}
return {
video,
channelId,
remoteChatInfos,
roomKey
}
}
async function _connectionInfos (
options: RegisterServerOptions,
settings: SettingEntries,
params: GetConverseJSParamsParams,
roomInfos: RoomInfos
): Promise<{
prosodyDomain: string
localAnonymousJID: string
localBoshUri: string
localWsUri: string | null
remoteConnectionInfos: WCRemoteConnectionInfos | undefined
roomJID: string
customEmojisUrl?: string
} | InitConverseJSParamsError> {
const { video, remoteChatInfos, channelId, roomKey } = roomInfos
const prosodyDomain = await getProsodyDomain(options)
const localAnonymousJID = 'anon.' + prosodyDomain
const localBoshUri = getBoshUri(options)
const localWsUri = settings['disable-websocket']
? null
: (getWSUri(options) ?? null)
let remoteConnectionInfos: WCRemoteConnectionInfos | undefined
let roomJID: string
let customEmojisUrl: string | undefined
if (video?.remote) {
const canWebsocketS2S = !settings['federation-no-remote-chat'] && !settings['disable-websocket']
const canDirectS2S = !settings['federation-no-remote-chat'] && !!settings['prosody-room-allow-s2s']
try {
remoteConnectionInfos = await _remoteConnectionInfos(remoteChatInfos ?? false, canWebsocketS2S, canDirectS2S)
} catch (err) {
options.peertubeHelpers.logger.error(err)
remoteConnectionInfos = undefined
}
if (!remoteConnectionInfos) {
return {
isError: true,
code: 404,
message: 'No compatible way to connect to remote chat'
}
}
roomJID = remoteConnectionInfos.roomJID
// TODO: fill customEmojisUrl (how to get the info? is the remote livechat compatible?)
} else {
try {
roomJID = await _localRoomJID(
options,
settings,
prosodyDomain,
roomKey,
video,
channelId,
params.forcetype ?? false
)
if (video?.channelId) {
customEmojisUrl = await Emojis.singletonSafe()?.channelCustomEmojisUrl(video.channelId)
}
} catch (err) {
options.peertubeHelpers.logger.error(err)
return {
isError: true,
code: 500,
message: 'An error occured'
}
}
}
return {
prosodyDomain,
localAnonymousJID,
localBoshUri,
localWsUri,
remoteConnectionInfos,
roomJID,
customEmojisUrl
}
}
interface WCRemoteConnectionInfos {
roomJID: string
anonymous?: {
userJID: string
boshUri: string
wsUri?: string
}
authenticated?: boolean
externalAuthCompatible: boolean
}
async function _remoteConnectionInfos (
remoteChatInfos: LiveChatJSONLDAttributeV1,
canWebsocketS2S: boolean,
canDirectS2S: boolean
): Promise {
if (!remoteChatInfos) { throw new Error('Should have remote chat infos for remote videos') }
if (remoteChatInfos.type !== 'xmpp') { throw new Error('Should have remote xmpp chat infos for remote videos') }
const connectionInfos: WCRemoteConnectionInfos = {
roomJID: remoteChatInfos.jid,
externalAuthCompatible: false
}
if (compatibleRemoteAuthenticatedConnectionEnabled(remoteChatInfos, canWebsocketS2S, canDirectS2S)) {
connectionInfos.authenticated = true
}
const anonymousCI = anonymousConnectionInfos(remoteChatInfos ?? false)
if (anonymousCI?.boshUri) {
connectionInfos.anonymous = {
userJID: anonymousCI.userJID,
boshUri: anonymousCI.boshUri,
wsUri: anonymousCI.wsUri
}
}
if (remoteChatInfos.xmppserver.external) {
// To be able to connect to a remote livechat using an external account,
// The remote server MUST have livechat >= 9.0.0...
// So we flag the connection as compatible or not, and we will disable the feature if not compatible.
connectionInfos.externalAuthCompatible = true
}
return connectionInfos
}
async function _localRoomJID (
options: RegisterServerOptions,
settings: SettingEntries,
prosodyDomain: string,
roomKey: string,
video: MVideoThumbnail | undefined,
channelId: number,
forceType: boolean
): Promise {
// Computing the room name...
let room: string
if (forceType) {
// 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
}
}
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}}')) {
// FIXME: this should no more exists, since we removed options to include other chat server.
// So we should remove this code. (and simplify the above code)
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
options.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)
}
return room
}
export {
getConverseJSParams,
InitConverseJSParamsError
}