diff --git a/server/lib/configuration/channel/init.ts b/server/lib/configuration/channel/init.ts new file mode 100644 index 00000000..43245979 --- /dev/null +++ b/server/lib/configuration/channel/init.ts @@ -0,0 +1,55 @@ +import type { RegisterServerOptions, MVideoFullLight, VideoChannel } from '@peertube/peertube-types' +import { RoomChannel } from '../../room-channel' + +/** + * Register stuffs related to channel configuration + */ +async function initChannelConfiguration (options: RegisterServerOptions): Promise { + const logger = options.peertubeHelpers.logger + const registerHook = options.registerHook + logger.info('Registring room-channel hooks...') + + registerHook({ + target: 'action:api.video.deleted', + handler: async (params: { video: MVideoFullLight }) => { + // 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 + 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. + // So we don't need to check which case is effective, just delete video_uuid@mucdomain. + try { + RoomChannel.singleton().removeRoom(video.uuid) + } catch (err) { + logger.error(err) + } + + // Note: we don't delete the room. So that admins can check logs afterward, if any doubts. + } + }) + + registerHook({ + target: 'action:api.video-channel.deleted', + 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... + 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. + // In first case, nothing to do... in the second, we must delete. + // So we don't need to check which case is effective, just delete video_uuid@mucdomain. + try { + RoomChannel.singleton().removeChannel(channelId) + } catch (err) { + logger.error(err) + } + + // Note: we don't delete the room. So that admins can check logs afterward, if any doubts. + } + }) +} + +export { + initChannelConfiguration +} diff --git a/server/lib/room-channel/index.ts b/server/lib/room-channel/index.ts new file mode 100644 index 00000000..11487413 --- /dev/null +++ b/server/lib/room-channel/index.ts @@ -0,0 +1 @@ +export * from './room-channel-class' diff --git a/server/lib/room-channel/room-channel-class.ts b/server/lib/room-channel/room-channel-class.ts new file mode 100644 index 00000000..6c2c13d2 --- /dev/null +++ b/server/lib/room-channel/room-channel-class.ts @@ -0,0 +1,281 @@ +import type { RegisterServerOptions } from '@peertube/peertube-types' +import { getProsodyDomain } from '../prosody/config/domain' +import * as path from 'path' +import * as fs from 'fs' + +let singleton: RoomChannel | undefined + +/** + * Class used to request some informations about relation between rooms and channels. + */ +class RoomChannel { + protected readonly options: RegisterServerOptions + protected readonly prosodyDomain: string + protected readonly dataFilePath: string + protected readonly logger: { + debug: (s: string) => void + info: (s: string) => void + warn: (s: string) => void + error: (s: string) => void + } + + protected room2Channel: Map = new Map() + protected channel2Rooms: Map> = new Map>() + + constructor (params: { + options: RegisterServerOptions + prosodyDomain: string + dataFilePath: string + }) { + this.options = params.options + this.prosodyDomain = params.prosodyDomain + this.dataFilePath = params.dataFilePath + + const logger = params.options.peertubeHelpers.logger + this.logger = { + debug: (s) => logger.debug('[RoomChannel] ' + s), + info: (s) => logger.info('[RoomChannel] ' + s), + warn: (s) => logger.warn('[RoomChannel] ' + s), + error: (s) => logger.error('[RoomChannel] ' + s) + } + } + + /** + * Instanciate the singleton + */ + public static async initSingleton (options: RegisterServerOptions): Promise { + const prosodyDomain = await getProsodyDomain(options) + const dataFilePath = path.resolve( + options.peertubeHelpers.plugin.getDataDirectoryPath(), + 'room-channel', + prosodyDomain + '.json' + ) + + singleton = new RoomChannel({ + options, + prosodyDomain, + dataFilePath + }) + + return singleton + } + + /** + * frees the singleton + */ + public static async destroySingleton (): Promise { + if (!singleton) { return } + await singleton.sync() + singleton = undefined + } + + /** + * Gets the singleton, or raise an exception if it is too soon. + * @returns the singleton + */ + public static singleton (): RoomChannel { + if (!singleton) { + throw new Error('RoomChannel singleton is not initialized yet') + } + return singleton + } + + /** + * Reads data from the room-channel data file. + * @return Returns true if the data where found and valid. If there is no data (or no valid data), returns false. + */ + public async readData (): Promise { + // Reading the data file (see https://livingston.frama.io/peertube-plugin-livechat/fr/technical/data/) + + this.room2Channel.clear() + this.channel2Rooms.clear() + + let content: string + try { + content = (await fs.promises.readFile(this.dataFilePath)).toString() + } catch (err) { + this.logger.info('Failed reading room-channel data file (' + this.dataFilePath + '), assuming it does not exists') + return false + } + content ??= '{}' + + let data: any + try { + data = JSON.parse(content) + } catch (err) { + this.logger.error('Unable to parse the content of the room-channel data file, will start with an empty database.') + return false + } + + if (typeof data !== 'object') { + this.logger.error('Invalid room-channel data file content') + return false + } + + for (const k in data) { + if (!/^\d+$/.test(k)) { + this.logger.error('Invalid channel ID type, should be a number, dropping') + continue + } + const channelId = parseInt(k) + const rooms = data[k] + if (!Array.isArray(rooms)) { + this.logger.error('Invalid room list for Channel ' + channelId.toString() + ', dropping') + continue + } + + const c2r = new Map() + this.channel2Rooms.set(channelId, c2r) + + for (const jid of rooms) { + if (typeof jid !== 'string') { + this.logger.error('Invalid room jid for Channel ' + channelId.toString() + ', dropping') + continue + } + c2r.set(jid, true) + this.room2Channel.set(jid, channelId) + } + } + + return true + } + + /** + * Rebuilt the data from scratch. + * Can be used for the initial migration. + */ + public async rebuildData (): Promise { + this.logger.error('rebuildData Not implemented yet') + await this.sync() // FIXME: or maybe scheduleSync ? + } + + /** + * Syncs data to disk. + */ + public async sync (): Promise { + this.logger.error('sync Not implemented yet') + } + + /** + * Schedules a sync. + * 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 { + this.logger.error('scheduleSync Not implemented yet') + } + + /** + * Sets a relation between room and channel id + * @param channelId The channel ID + * @param roomJID The room JID. Can be the local part only, or the full JID. + * In the second case, the domain will be checked. + */ + public link (channelId: number | string, roomJIDParam: string): void { + channelId = parseInt(channelId.toString()) + if (isNaN(channelId)) { + this.logger.error('Invalid channelId, we wont link') + return + } + + const roomJID = this._canonicJID(roomJIDParam) + if (!roomJID) { + this.logger.error('Invalid room JID, we wont link') + return + } + + // First, if the room was linked to another channel, we must unlink. + const previousChannelId = this.room2Channel.get(roomJID) + if (previousChannelId) { + this.room2Channel.delete(roomJID) + const previousRooms = this.channel2Rooms.get(previousChannelId) + if (previousRooms) { + previousRooms.delete(roomJID) + } + } + + this.room2Channel.set(roomJID, channelId) + let rooms = this.channel2Rooms.get(channelId) + if (!rooms) { + rooms = new Map() + this.channel2Rooms.set(channelId, rooms) + } + rooms.set(roomJID, true) + + this.scheduleSync() + } + + /** + * Removes all relations for this room + * @param roomJID the room JID + */ + public removeRoom (roomJIDParam: string): void { + const roomJID = this._canonicJID(roomJIDParam) + if (!roomJID) { + this.logger.error('Invalid room JID, we wont link') + return + } + + const channelId = this.room2Channel.get(roomJID) + if (channelId) { + const rooms = this.channel2Rooms.get(channelId) + if (rooms) { + rooms.delete(roomJID) + } + } + + this.room2Channel.delete(roomJID) + + this.scheduleSync() + } + + /** + * Removes all relations for this channel + * @param channelId the channel id + */ + public removeChannel (channelId: number | string): void { + channelId = parseInt(channelId.toString()) + if (isNaN(channelId)) { + this.logger.error('Invalid channelId, we wont remove') + return + } + + const rooms = this.channel2Rooms.get(channelId) + if (rooms) { + for (const jid of rooms.keys()) { + // checking the consistency... only removing if the channel is the current one + if (this.room2Channel.get(jid) === channelId) { + this.room2Channel.delete(jid) + } + } + } + + this.channel2Rooms.delete(channelId) + + this.scheduleSync() + } + + protected _canonicJID (roomJID: string): string | null { + const splits = roomJID.split('@') + if (splits.length < 2) { + return roomJID + } + if (splits.length > 2) { + this.logger.error('The room JID contains multiple @, not valid') + return null + } + if (splits[1] !== this.prosodyDomain) { + this.logger.error('The room JID is not on the correct domain') + return null + } + + return splits[0] + } +} + +export { + RoomChannel +} + +// 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/room/channel.ts b/server/lib/room/channel.ts deleted file mode 100644 index 4bd8e257..00000000 --- a/server/lib/room/channel.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { RegisterServerOptions } from '@peertube/peertube-types' -import { getProsodyDomain } from '../prosody/config/domain' -import * as path from 'path' -import * as fs from 'fs' - -/** - * Stores that given room is related to given channel. - * Can throw an exception. - * @param options server options - * @param channelId channel ID - * @param roomJIDLocalPart room JID (only the local part) - */ -async function setChannel2Room ( - options: RegisterServerOptions, - channelId: number, - roomJIDLocalPart: string -): Promise { - const logger = options.peertubeHelpers.logger - logger.info(`Calling setChannel2Room for channel ${channelId} and room ${roomJIDLocalPart}...`) - - _checkParameters(channelId, roomJIDLocalPart) - - const prosodyDomain = await getProsodyDomain(options) - - { - const [channel2roomDir, channel2room] = await _getFilePath( - options, channelId, roomJIDLocalPart, prosodyDomain, 'channel2room' - ) - await fs.promises.mkdir(channel2roomDir, { - recursive: true - }) - await fs.promises.writeFile( - channel2room, - '' - ) - } - - { - const [room2channelDir, room2channel, room2channelFile] = await _getFilePath( - options, channelId, roomJIDLocalPart, prosodyDomain, 'room2channel' - ) - await fs.promises.mkdir(room2channelDir, { - recursive: true - }) - - // The video's channel could have changed. We must delete any deprecated file. - const previousFiles = await fs.promises.readdir(room2channelDir) - for (const filename of previousFiles) { - if (filename !== room2channelFile) { - const p = path.resolve(room2channelDir, filename) - logger.info('Cleaning a deprecated room2channelFile: ' + p) - await fs.promises.unlink(p) - } - } - - await fs.promises.writeFile( - room2channel, - '' - ) - } -} - -function _checkParameters (channelId: number | string, roomJIDLocalPart: string): void { - channelId = channelId.toString() - if (!/^\d+$/.test(channelId)) { - throw new Error(`Invalid Channel ID: ${channelId}`) - } - - if (!/^[\w-.]+$/.test(roomJIDLocalPart)) { // channel.X or video uuid - throw new Error(`Invalid ROOM JID: ${channelId}`) - } -} - -async function _getFilePath ( - options: RegisterServerOptions, - channelId: number | string, - roomJIDLocalPart: string, - prosodyDomain: string, - way: 'channel2room' | 'room2channel' -): Promise<[string, string, string]> { - channelId = channelId.toString() - - const roomJID = roomJIDLocalPart + '@' + prosodyDomain - - if (way === 'channel2room') { - const dir = path.resolve( - options.peertubeHelpers.plugin.getDataDirectoryPath(), - 'channel2room', - channelId - ) - return [ - dir, - path.resolve(dir, roomJID), - roomJID - ] - } else if (way === 'room2channel') { - const dir = path.resolve( - options.peertubeHelpers.plugin.getDataDirectoryPath(), - 'room2channel', - roomJID - ) - return [ - dir, - path.resolve(dir, channelId), - channelId - ] - } else { - throw new Error('Invalid way parameter') - } -} - -export { - setChannel2Room -} diff --git a/server/lib/routers/api/room.ts b/server/lib/routers/api/room.ts index 8be6cdf1..fbac8b5e 100644 --- a/server/lib/routers/api/room.ts +++ b/server/lib/routers/api/room.ts @@ -6,7 +6,7 @@ import { getCheckAPIKeyMiddleware } from '../../middlewares/apikey' import { Affiliations, getVideoAffiliations, getChannelAffiliations } from '../../prosody/config/affiliations' import { fillVideoCustomFields } from '../../custom-fields' import { getChannelInfosById } from '../../database/channel' -import { setChannel2Room } from '../../room/channel' +import { RoomChannel } from '../../room-channel' // See here for description: https://modules.prosody.im/mod_muc_http_defaults.html interface RoomDefaults { @@ -80,7 +80,7 @@ async function initRoomApiRouter (options: RegisterServerOptions, router: Router affiliations: affiliations } - await setChannel2Room(options, channelId, jid) + await RoomChannel.singleton().link(channelId, jid) res.json(roomDefaults) } else { @@ -132,7 +132,7 @@ async function initRoomApiRouter (options: RegisterServerOptions, router: Router affiliations: affiliations } - await setChannel2Room(options, video.channelId, jid) + await RoomChannel.singleton().link(video.channelId, jid) res.json(roomDefaults) } diff --git a/server/main.ts b/server/main.ts index c044cfc7..7ea1cc1e 100644 --- a/server/main.ts +++ b/server/main.ts @@ -4,10 +4,12 @@ import { initSettings } from './lib/settings' import { initCustomFields } from './lib/custom-fields' import { initRouters } from './lib/routers/index' import { initFederation } from './lib/federation/init' +import { initChannelConfiguration } from './lib/configuration/channel/init' import { initRSS } from './lib/rss/init' import { prepareProsody, ensureProsodyRunning, ensureProsodyNotRunning } from './lib/prosody/ctl' import { unloadDebugMode } from './lib/debug' import { loadLoc } from './lib/loc' +import { RoomChannel } from './lib/room-channel' import decache from 'decache' // FIXME: Peertube unregister don't have any parameter. @@ -16,6 +18,7 @@ let OPTIONS: RegisterServerOptions | undefined async function register (options: RegisterServerOptions): Promise { OPTIONS = options + const logger = options.peertubeHelpers.logger // This is a trick to check that peertube is at least in version 3.2.0 if (!options.peertubeHelpers.plugin) { @@ -24,6 +27,10 @@ async function register (options: RegisterServerOptions): Promise { // First: load languages files, so we can localize strings. await loadLoc() + // Then load the RoomChannel singleton + const roomChannelSingleton = await RoomChannel.initSingleton(options) + // roomChannelNeedsDataInit: if true, means that the data file does not exist (or is invalid), so we must initiate it + const roomChannelNeedsDataInit = !await roomChannelSingleton.readData() await migrateSettings(options) @@ -31,11 +38,21 @@ async function register (options: RegisterServerOptions): Promise { await initCustomFields(options) await initRouters(options) await initFederation(options) + await initChannelConfiguration(options) await initRSS(options) try { await prepareProsody(options) await ensureProsodyRunning(options) + + if (roomChannelNeedsDataInit) { + logger.info('The RoomChannel singleton has not found data, we must rebuild') + // no need to wait here, can be done without await. + roomChannelSingleton.rebuildData().then( + () => { logger.info('RoomChannel singleton rebuild done') }, + (reason) => { logger.error('RoomChannel singleton rebuild failed: ' + (reason as string)) } + ) + } } catch (error) { options.peertubeHelpers.logger.error('Error when launching Prosody: ' + (error as string)) } @@ -52,6 +69,8 @@ async function unregister (): Promise { unloadDebugMode() + await RoomChannel.destroySingleton() + const module = __filename OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`) // Peertube calls decache(plugin) on register, not unregister. diff --git a/support/documentation/content/en/technical/data/_index.md b/support/documentation/content/en/technical/data/_index.md index 3fe40d6d..40e9cf0d 100644 --- a/support/documentation/content/en/technical/data/_index.md +++ b/support/documentation/content/en/technical/data/_index.md @@ -55,28 +55,34 @@ The `channelConfigurationOptions` folder contains JSON files describing channels Filenames are like `1.json` where `1` is the channel id. The content of the files are similar to the content sent by the front-end when saving these configuration. -## channel2room and room2channel +## room-channel/muc_domain.json -Some parts of the plugin need a quick way to get the channel id from the room id, or the all room id from a channel id. +Some parts of the plugin need a quick way to get the channel id from the room Jabber ID, or the all room Jabber ID from a channel id. We won't use SQL queries, because we only want such information for video that have a chatroom. -So we have 2 folders: `channel2room` and `room2channel`. -When a chatroom is created, we create 2 empty files: +So we will store in the `room-channel/muc_domain.json` file (where `muc_domain` is the actual MUC domain, +something like `room.instance.tld`) a JSON object representing these relations. -* `channel2room/channel_id/room_id@muc_domain` -* `room2channel/room_id@muc_domain/channel_id` +In the JSON object, keys are the channel ID, values are arrays of strings representing the rooms JIDs local part (without the MUC domain). -Where: +When a chatroom is created, the corresponding entry will be added. -* `muc_domain` is the room's domain (should be `room.your_instance.tld`) -* `channel_id` is the channel numerical id -* `room_id` is the local part of the room JID +Here is a sample file: -So we can easily list all rooms for a given channel id, just by listing files in `channel2room`. -Or get the channel id for a room JID (Jabber ID). +```json +{ + 1: [ + "8df24108-6e70-4fc8-b1cc-f2db7fcdd535" + ] +} +``` -Note: we include muc_domain, in case the instance domain changes. In such case, existing rooms -could get lost, and we want a way to ignore them to avoid gettings errors. +This file is loaded at the plugin startup into an object that can manipulate these data. + +So we can easily list all rooms for a given channel id or get the channel id for a room JID (Jabber ID). + +Note: we include the MUC domain (`room.instance.tld`) in the filename in case the instance domain changes. +In such case, existing rooms could get lost, and we want a way to ignore them to avoid gettings errors. Note: there could be some inconsistencies, when video or rooms are deleted. The code must take this into account, and always double check room or channel existence.