From e4683cf28298d18772c4cccc774098dd027ff7e4 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 11 Sep 2023 17:38:31 +0200 Subject: [PATCH] WIP: store and get relation between rooms and channels: * rebuildData * handling video update (to check for channel changes) --- server/lib/configuration/channel/init.ts | 50 +++++++++++ server/lib/prosody/api/host.ts | 41 +++++++++ server/lib/prosody/api/list-rooms.ts | 41 +++++++++ server/lib/room-channel/room-channel-class.ts | 89 +++++++++++++++++-- server/lib/routers/webchat.ts | 28 ++---- .../content/en/technical/data/_index.md | 4 +- 6 files changed, 221 insertions(+), 32 deletions(-) create mode 100644 server/lib/prosody/api/host.ts create mode 100644 server/lib/prosody/api/list-rooms.ts diff --git a/server/lib/configuration/channel/init.ts b/server/lib/configuration/channel/init.ts index 43245979..b6dd78b3 100644 --- a/server/lib/configuration/channel/init.ts +++ b/server/lib/configuration/channel/init.ts @@ -1,5 +1,7 @@ import type { RegisterServerOptions, MVideoFullLight, VideoChannel } from '@peertube/peertube-types' import { RoomChannel } from '../../room-channel' +import { fillVideoCustomFields } from '../../custom-fields' +import { videoHasWebchat } from '../../../../shared/lib/video' /** * Register stuffs related to channel configuration @@ -15,6 +17,7 @@ async function initChannelConfiguration (options: RegisterServerOptions): Promis // When a video is deleted, we can delete the channel2room and room2channel files. // Note: don't need to check if there is a chat for this video, just deleting existing files... const video = params.video + if (video.remote) { return } logger.info(`Video ${video.uuid} deleted, removing 'channel configuration' related stuff.`) // Here the associated channel can be either channel.X@mucdomain or video_uuid@mucdomain. // In first case, nothing to do... in the second, we must delete. @@ -34,6 +37,7 @@ async function initChannelConfiguration (options: RegisterServerOptions): Promis handler: async (params: { channel: VideoChannel }) => { // When a video is deleted, we can delete the channel2room and room2channel files. // Note: don't need to check if there is a chat for this video, just deleting existing files... + if (!params.channel.isLocal) { return } const channelId = params.channel.id logger.info(`Channel ${channelId} deleted, removing 'channel configuration' related stuff.`) // Here the associated channel can be either channel.X@mucdomain or video_uuid@mucdomain. @@ -48,6 +52,52 @@ async function initChannelConfiguration (options: RegisterServerOptions): Promis // Note: we don't delete the room. So that admins can check logs afterward, if any doubts. } }) + + registerHook({ + target: 'action:api.video.updated', + handler: async (params: { video: MVideoFullLight }) => { + // When a video is updated, the channel could change. + // So we ensure the room-channel link is ok. + // But we can only do this if the video has a chatroom! + const video = params.video + logger.info(`Video ${video.uuid} updated, updating room-channel informations.`) + try { + if (video.remote) { return } + const settings = await options.settingsManager.getSettings([ + 'chat-per-live-video', + 'chat-all-lives', + 'chat-all-non-lives', + 'chat-videos-list', + 'prosody-room-type' + ]) + + await fillVideoCustomFields(options, video) + const hasChat = await videoHasWebchat({ + 'chat-per-live-video': !!settings['chat-per-live-video'], + 'chat-all-lives': !!settings['chat-all-lives'], + 'chat-all-non-lives': !!settings['chat-all-non-lives'], + 'chat-videos-list': settings['chat-videos-list'] as string + }, video) + + if (!hasChat) { + logger.debug(`Video ${video.uuid} has no chat, skipping`) + return + } + + let roomLocalPart: string + if (settings['prosody-room-type'] === 'channel') { + roomLocalPart = 'channel.' + video.channelId.toString() + } else { + roomLocalPart = video.uuid + } + + logger.debug(`Ensuring a room-channel link between room ${roomLocalPart} and channel ${video.channelId}`) + RoomChannel.singleton().link(video.channelId, roomLocalPart) + } catch (err) { + logger.error(err) + } + } + }) } export { diff --git a/server/lib/prosody/api/host.ts b/server/lib/prosody/api/host.ts new file mode 100644 index 00000000..0d403c45 --- /dev/null +++ b/server/lib/prosody/api/host.ts @@ -0,0 +1,41 @@ +interface ProsodyHost { + host: string + port: string +} + +let current: ProsodyHost | undefined + +/** + * When loading Prosody, keep track of the current host and port. + * @param host host + * @param port port + */ +function setCurrentProsody (host: string, port: string): void { + current = { + host, + port + } +} + +/** + * When stopping Prosody, delete current host and port. + */ +function delCurrentProsody (): void { + current = undefined +} + +/** + * Get the current Prosody host infos. + * @returns Prosody host info + */ +function getCurrentProsody (): ProsodyHost | null { + // cloning to avoid issues + if (!current) { return null } + return Object.assign({}, current) +} + +export { + setCurrentProsody, + delCurrentProsody, + getCurrentProsody +} diff --git a/server/lib/prosody/api/list-rooms.ts b/server/lib/prosody/api/list-rooms.ts new file mode 100644 index 00000000..19781e4e --- /dev/null +++ b/server/lib/prosody/api/list-rooms.ts @@ -0,0 +1,41 @@ +import type { RegisterServerOptions } from '@peertube/peertube-types' +import { getCurrentProsody } from './host' +import { getAPIKey } from '../../apikey' +const got = require('got') + +interface ProsodyRoomDesc { + jid: string + localpart: string + name: string + lang: string + description: string + lasttimestamp?: number +} + +async function listProsodyRooms (options: RegisterServerOptions): Promise { + const logger = options.peertubeHelpers.logger + + const currentProsody = getCurrentProsody() + if (!currentProsody) { + throw new Error('It seems that prosody is not binded... Cant list rooms.') + } + + // Requesting on localhost, because currentProsody.host does not always resolves correctly (docker use case, ...) + const apiUrl = `http://localhost:${currentProsody.port}/peertubelivechat_list_rooms/list-rooms` + logger.debug('Calling list rooms API on url: ' + apiUrl) + const rooms = await got(apiUrl, { + method: 'GET', + headers: { + authorization: 'Bearer ' + await getAPIKey(options), + host: currentProsody.host + }, + responseType: 'json', + resolveBodyOnly: true + }) + + return rooms +} + +export { + listProsodyRooms +} diff --git a/server/lib/room-channel/room-channel-class.ts b/server/lib/room-channel/room-channel-class.ts index c8fbef54..c8419793 100644 --- a/server/lib/room-channel/room-channel-class.ts +++ b/server/lib/room-channel/room-channel-class.ts @@ -1,5 +1,7 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' import { getProsodyDomain } from '../prosody/config/domain' +import { listProsodyRooms } from '../prosody/api/list-rooms' +import { getChannelInfosById } from '../database/channel' import * as path from 'path' import * as fs from 'fs' @@ -21,6 +23,7 @@ class RoomChannel { protected room2Channel: Map = new Map() protected channel2Rooms: Map> = new Map>() + protected needSync: boolean = false constructor (params: { options: RegisterServerOptions @@ -115,6 +118,7 @@ class RoomChannel { protected _readData (data: any): boolean { this.room2Channel.clear() this.channel2Rooms.clear() + this.needSync = true if (typeof data !== 'object') { this.logger.error('Invalid room-channel data file content') @@ -152,9 +156,56 @@ class RoomChannel { /** * Rebuilt the data from scratch. * Can be used for the initial migration. + * Can also be scheduled daily, or on an admin action (not sure it will be done, at the time of the writing). */ public async rebuildData (): Promise { - this.logger.error('rebuildData Not implemented yet') + const data: any = {} + + const rooms = await listProsodyRooms(this.options) + for (const room of rooms) { + let channelId: string | number | undefined + + const matches = room.localpart.match(/^channel\.(\d+)$/) + if (matches?.[1]) { + channelId = parseInt(matches[1]) + if (isNaN(channelId)) { + this.logger.error(`Invalid room JID '${room.localpart}'`) + continue + } + // Checking that channel still exists + const channelInfos = await getChannelInfosById(this.options, channelId) + if (!channelInfos) { + this.logger.debug( + `Ignoring room ${room.localpart}, because channel ${channelId} seems to not exist anymore` + ) + continue + } + } else { + const uuid = room.localpart + const video = await this.options.peertubeHelpers.videos.loadByIdOrUUID(uuid) + if (!video) { + this.logger.debug( + `Ignoring room ${room.localpart}, because video ${uuid} seems to not exist anymore` + ) + continue + } + channelId = video.channelId + } + + if (!channelId) { + this.logger.error(`Did not find channelId for ${room.localpart}`) + continue + } + channelId = channelId.toString() + if (!(channelId in data)) { + this.logger.debug(`Room ${room.localpart} is associated to channel ${channelId}`) + data[channelId] = [room.localpart] + } + } + + // This part must be done atomicly: + this._readData(data) + await this.sync() // FIXME: or maybe scheduleSync ? } @@ -162,7 +213,9 @@ class RoomChannel { * Syncs data to disk. */ public async sync (): Promise { + if (!this.needSync) { return } this.logger.error('sync Not implemented yet') + this.needSync = false // Note: must be done at the right moment } /** @@ -170,6 +223,7 @@ class RoomChannel { * Each times data are modified, we can schedule a sync, but we don't have to wait the file writing to be done. */ public scheduleSync (): void { + if (!this.needSync) { return } this.logger.error('scheduleSync Not implemented yet') } @@ -195,20 +249,31 @@ class RoomChannel { // First, if the room was linked to another channel, we must unlink. const previousChannelId = this.room2Channel.get(roomJID) if (previousChannelId) { - this.room2Channel.delete(roomJID) + if (this.room2Channel.delete(roomJID)) { + this.needSync = true + } const previousRooms = this.channel2Rooms.get(previousChannelId) if (previousRooms) { - previousRooms.delete(roomJID) + if (previousRooms.delete(roomJID)) { + this.needSync = true + } } } - this.room2Channel.set(roomJID, channelId) + if (this.room2Channel.get(roomJID) !== channelId) { + this.room2Channel.set(roomJID, channelId) + this.needSync = true + } let rooms = this.channel2Rooms.get(channelId) if (!rooms) { rooms = new Map() this.channel2Rooms.set(channelId, rooms) + this.needSync = true + } + if (!rooms.has(roomJID)) { + rooms.set(roomJID, true) + this.needSync = true } - rooms.set(roomJID, true) this.scheduleSync() } @@ -228,11 +293,15 @@ class RoomChannel { if (channelId) { const rooms = this.channel2Rooms.get(channelId) if (rooms) { - rooms.delete(roomJID) + if (rooms.delete(roomJID)) { + this.needSync = true + } } } - this.room2Channel.delete(roomJID) + if (this.room2Channel.delete(roomJID)) { + this.needSync = true + } this.scheduleSync() } @@ -254,11 +323,14 @@ class RoomChannel { // checking the consistency... only removing if the channel is the current one if (this.room2Channel.get(jid) === channelId) { this.room2Channel.delete(jid) + this.needSync = true } } } - this.channel2Rooms.delete(channelId) + if (this.channel2Rooms.delete(channelId)) { + this.needSync = true + } this.scheduleSync() } @@ -287,4 +359,3 @@ export { // TODO: schedule rebuild every X hours/days // TODO: write to disk, debouncing writes -// TODO: only write if there is data changes diff --git a/server/lib/routers/webchat.ts b/server/lib/routers/webchat.ts index 42f29e8d..336804d2 100644 --- a/server/lib/routers/webchat.ts +++ b/server/lib/routers/webchat.ts @@ -4,13 +4,13 @@ import type { ProsodyListRoomsResult, ProsodyListRoomsResultRoom } from '../../. import { createProxyServer } from 'http-proxy' import { RegisterServerOptionsV5, isUserAdmin } from '../helpers' import { asyncMiddleware } from '../middlewares/async' -import { getAPIKey } from '../apikey' -import { getChannelInfosById } from '../database/channel' import { isAutoColorsAvailable, areAutoColorsValid, AutoColors } from '../../../shared/lib/autocolors' import { fetchMissingRemoteServerInfos } from '../federation/fetch-infos' import { getConverseJSParams } from '../conversejs/params' +import { setCurrentProsody, delCurrentProsody } from '../prosody/api/host' +import { getChannelInfosById } from '../database/channel' +import { listProsodyRooms } from '../prosody/api/list-rooms' import * as path from 'path' -const got = require('got') const fs = require('fs').promises @@ -18,7 +18,6 @@ interface ProsodyProxyInfo { host: string port: string } -let currentProsodyProxyInfo: ProsodyProxyInfo | null = null let currentHttpBindProxy: ReturnType | null = null let currentWebsocketProxy: ReturnType | null = null let currentS2SWebsocketProxy: ReturnType | null = null @@ -218,21 +217,8 @@ async function initWebchatRouter (options: RegisterServerOptionsV5): Promise