diff --git a/server/lib/configuration/bot.ts b/server/lib/configuration/bot.ts index f2115060..c22f59df 100644 --- a/server/lib/configuration/bot.ts +++ b/server/lib/configuration/bot.ts @@ -1,7 +1,6 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' -import { RoomConf } from 'xmppjs-chat-bot' +import type { RoomConf } from 'xmppjs-chat-bot' import { getProsodyDomain } from '../prosody/config/domain' -import { RoomChannel } from '../room-channel' import * as path from 'path' import * as fs from 'fs' @@ -89,30 +88,34 @@ class BotConfiguration { } /** - * Recompute the room configuration, and save it to disk. - * @param roomJIDParam room JID (local part only, or full JID) + * Update the bot configuration for a given room. + * @param roomJIDParam Room full or local JID + * @param conf Configuration to write */ - public async updateChannelConf (channelId: number | string, conf: ChannelCommonRoomConf): Promise { - const jids = RoomChannel.singleton().getChannelRoomJIDs(channelId) - - // cloning to avoid issues: - const roomConf: RoomConf = JSON.parse(JSON.stringify(conf)) - roomConf.domain = this.prosodyDomain - - for (const jid of jids) { - roomConf.local = jid - - if (!(roomConf.enabled ?? true)) { - // Bot disabled... If the room config file does not exist, no need to create - const current = await this._getRoomConf(jid) - if (!current) { - this.logger.debug(`Bot is disabled for channel ${channelId}, room ${jid} has not current conf, skipping`) - return - } - } - this.roomConfCache.set(jid, roomConf) - await this._writeRoomConf(jid) + public async update (roomJIDParam: string, conf: ChannelCommonRoomConf): Promise { + const roomJID = this._canonicJID(roomJIDParam) + if (!roomJID) { + this.logger.error('Invalid room JID') + return } + + const roomConf: RoomConf = Object.assign({ + local: roomJID, + domain: this.prosodyDomain + }, conf) + + if (!(roomConf.enabled ?? true)) { + // Bot disabled... If the room config file does not exist, no need to create + const current = await this._getRoomConf(roomJID) + if (!current) { + this.logger.debug(`Bot is disabled for room ${roomJID}, and room has not current conf, skipping`) + return + } + } + + this.logger.debug(`Setting and writing a new conf for room ${roomJID}`) + this.roomConfCache.set(roomJID, roomConf) + await this._writeRoomConf(roomJID) } /** @@ -137,6 +140,14 @@ class BotConfiguration { await this._writeRoomConf(roomJID) } + /** + * frees the singleton + */ + public static async destroySingleton (): Promise { + if (!singleton) { return } + singleton = undefined + } + /** * Get the room conf. * Note: the returned object is not cloned. So it can be modified @@ -215,5 +226,6 @@ class BotConfiguration { } export { - BotConfiguration + BotConfiguration, + ChannelCommonRoomConf } diff --git a/server/lib/configuration/channel/sanitize.ts b/server/lib/configuration/channel/sanitize.ts index 39d12710..d7d97abb 100644 --- a/server/lib/configuration/channel/sanitize.ts +++ b/server/lib/configuration/channel/sanitize.ts @@ -1,5 +1,5 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' -import type { ChannelConfigurationOptions, ChannelInfos } from '../../../../shared/lib/types' +import type { ChannelConfigurationOptions } from '../../../../shared/lib/types' /** * Sanitize data so that they can safely be used/stored for channel configuration configuration. @@ -11,7 +11,7 @@ import type { ChannelConfigurationOptions, ChannelInfos } from '../../../../shar */ async function sanitizeChannelConfigurationOptions ( _options: RegisterServerOptions, - _channelInfos: ChannelInfos, + _channelId: number | string, data: any ): Promise { const result = { diff --git a/server/lib/configuration/channel/storage.ts b/server/lib/configuration/channel/storage.ts index 2a12c128..6c916121 100644 --- a/server/lib/configuration/channel/storage.ts +++ b/server/lib/configuration/channel/storage.ts @@ -1,7 +1,8 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' -import type { ChannelConfiguration, ChannelInfos } from '../../../../shared/lib/types' +import type { ChannelConfigurationOptions } from '../../../../shared/lib/types' +import type { ChannelCommonRoomConf } from '../../configuration/bot' +import { RoomChannel } from '../../room-channel' import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize' -import { BotConfiguration } from '../../configuration/bot' import * as fs from 'fs' import * as path from 'path' @@ -10,32 +11,30 @@ import * as path from 'path' * Can throw an exception. * @param options Peertube server options * @param channelInfos Info from channel from which we want to get infos - * @returns Channel configuration data + * @returns Channel configuration data, or null if nothing is stored */ async function getChannelConfigurationOptions ( options: RegisterServerOptions, - channelInfos: ChannelInfos -): Promise { + channelId: number | string +): Promise { const logger = options.peertubeHelpers.logger - const filePath = _getFilePath(options, channelInfos) + const filePath = _getFilePath(options, channelId) if (!fs.existsSync(filePath)) { logger.debug('No stored data for channel, returning default values') - return { - channel: channelInfos, - configuration: { - bot: false, - bannedJIDs: [], - forbiddenWords: [] - } - } + return null } const content = await fs.promises.readFile(filePath, { encoding: 'utf-8' }) - const sanitized = await sanitizeChannelConfigurationOptions(options, channelInfos, JSON.parse(content)) + const sanitized = await sanitizeChannelConfigurationOptions(options, channelId, JSON.parse(content)) + return sanitized +} + +function getDefaultChannelConfigurationOptions (_options: RegisterServerOptions): ChannelConfigurationOptions { return { - channel: channelInfos, - configuration: sanitized + bot: false, + bannedJIDs: [], + forbiddenWords: [] } } @@ -43,14 +42,14 @@ async function getChannelConfigurationOptions ( * Save channel configuration options. * Can throw an exception. * @param options Peertube server options - * @param channelConfiguration data to save + * @param ChannelConfigurationOptions data to save */ async function storeChannelConfigurationOptions ( options: RegisterServerOptions, - channelConfiguration: ChannelConfiguration + channelId: number | string, + channelConfigurationOptions: ChannelConfigurationOptions ): Promise { - const channelInfos = channelConfiguration.channel - const filePath = _getFilePath(options, channelInfos) + const filePath = _getFilePath(options, channelId) if (!fs.existsSync(filePath)) { const dir = path.dirname(filePath) @@ -59,27 +58,40 @@ async function storeChannelConfigurationOptions ( } } - const jsonContent = JSON.stringify(channelConfiguration.configuration) + const jsonContent = JSON.stringify(channelConfigurationOptions) await fs.promises.writeFile(filePath, jsonContent, { encoding: 'utf-8' }) + RoomChannel.singleton().refreshChannelConfigurationOptions(channelId) +} + +/** + * Converts the channel configuration to the bot room configuration object (minus the room JID and domain) + * @param options server options + * @param channelConfigurationOptions The channel configuration + * @returns Partial bot room configuration + */ +function channelConfigurationOptionsToBotRoomConf ( + options: RegisterServerOptions, + channelConfigurationOptions: ChannelConfigurationOptions +): ChannelCommonRoomConf { const roomConf = { - enabled: channelConfiguration.configuration.bot, + enabled: channelConfigurationOptions.bot, // TODO: nick handlers: [] } - await BotConfiguration.singleton().updateChannelConf(channelInfos.id, roomConf) + return roomConf } function _getFilePath ( options: RegisterServerOptions, - channelInfos: ChannelInfos + channelId: number | string ): string { - const channelId = channelInfos.id // some sanitization, just in case... - if (!/^\d+$/.test(channelId.toString())) { + channelId = parseInt(channelId.toString()) + if (isNaN(channelId)) { throw new Error(`Invalid channelId: ${channelId}`) } @@ -92,5 +104,7 @@ function _getFilePath ( export { getChannelConfigurationOptions, + getDefaultChannelConfigurationOptions, + channelConfigurationOptionsToBotRoomConf, storeChannelConfigurationOptions } diff --git a/server/lib/room-channel/room-channel-class.ts b/server/lib/room-channel/room-channel-class.ts index 891e8862..1f8b67be 100644 --- a/server/lib/room-channel/room-channel-class.ts +++ b/server/lib/room-channel/room-channel-class.ts @@ -1,7 +1,14 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' +import type { RoomConf } from 'xmppjs-chat-bot' import { getProsodyDomain } from '../prosody/config/domain' import { listProsodyRooms } from '../prosody/api/list-rooms' import { getChannelInfosById } from '../database/channel' +import { ChannelConfigurationOptions } from '../../../shared/lib/types' +import { + getChannelConfigurationOptions, + channelConfigurationOptionsToBotRoomConf +} from '../configuration/channel/storage' +import { BotConfiguration } from '../configuration/bot' import * as path from 'path' import * as fs from 'fs' @@ -24,6 +31,7 @@ class RoomChannel { protected room2Channel: Map = new Map() protected channel2Rooms: Map> = new Map>() protected needSync: boolean = false + protected roomConfToUpdate: Map = new Map() protected syncTimeout: ReturnType | undefined protected isWriting: boolean = false @@ -145,13 +153,14 @@ class RoomChannel { const c2r = new Map() this.channel2Rooms.set(channelId, c2r) - for (const jid of rooms) { - if (typeof jid !== 'string') { + for (const roomJID of rooms) { + if (typeof roomJID !== 'string') { this.logger.error('Invalid room jid for Channel ' + channelId.toString() + ', dropping') continue } - c2r.set(jid, true) - this.room2Channel.set(jid, channelId) + c2r.set(roomJID, true) + this.room2Channel.set(roomJID, channelId) + this.roomConfToUpdate.set(roomJID, true) } } @@ -234,6 +243,58 @@ class RoomChannel { await fs.promises.mkdir(path.dirname(this.dataFilePath), { recursive: true }) await fs.promises.writeFile(this.dataFilePath, JSON.stringify(data)) + + this.logger.debug('room-channel sync done, must sync room conf now') + // Note: getChannelConfigurationOptions has no cache for now, so we will handle it here + const channelConfigurationOptionsCache = new Map() + const roomJIDs = Array.from(this.roomConfToUpdate.keys()) + for (const roomJID of roomJIDs) { + const channelId = this.room2Channel.get(roomJID) // roomJID already normalized, so bypassing getRoomChannelId + if (channelId === undefined) { + // No more channel, must disable room! + this.logger.info(`Room ${roomJID} has no associated channel, ensuring there is no active bot conf`) + await BotConfiguration.singleton().disableRoom(roomJID) + this.roomConfToUpdate.delete(roomJID) + continue + } + // Must write the correct Channel conf for the room. + + if (!channelConfigurationOptionsCache.has(channelId)) { + try { + channelConfigurationOptionsCache.set( + channelId, + await getChannelConfigurationOptions(this.options, channelId) + ) + } catch (err) { + this.logger.error(err as string) + this.logger.error('Failed reading channel configuration, will assume there is none.') + channelConfigurationOptionsCache.set( + channelId, + null + ) + } + } + const channelConfigurationOptions = channelConfigurationOptionsCache.get(channelId) + if (!channelConfigurationOptions) { + // no channel configuration, disabling + this.logger.info(`Room ${roomJID} has not associated channel options, ensuring there is no active bot conf`) + await BotConfiguration.singleton().disableRoom(roomJID) + this.roomConfToUpdate.delete(roomJID) + continue + } + + this.logger.info(`Room ${roomJID} has associated channel options, writing it`) + const botConf: RoomConf = Object.assign( + { + local: roomJID, + domain: this.prosodyDomain + }, + channelConfigurationOptionsToBotRoomConf(this.options, channelConfigurationOptions) + ) + + await BotConfiguration.singleton().update(roomJID, botConf) + } + this.logger.info('Syncing done.') } catch (err) { this.logger.error(err as string) @@ -296,11 +357,13 @@ class RoomChannel { if (previousChannelId) { if (this.room2Channel.delete(roomJID)) { this.needSync = true + this.roomConfToUpdate.set(roomJID, true) } const previousRooms = this.channel2Rooms.get(previousChannelId) if (previousRooms) { if (previousRooms.delete(roomJID)) { this.needSync = true + this.roomConfToUpdate.set(roomJID, true) } } } @@ -308,6 +371,7 @@ class RoomChannel { if (this.room2Channel.get(roomJID) !== channelId) { this.room2Channel.set(roomJID, channelId) this.needSync = true + this.roomConfToUpdate.set(roomJID, true) } let rooms = this.channel2Rooms.get(channelId) if (!rooms) { @@ -318,6 +382,7 @@ class RoomChannel { if (!rooms.has(roomJID)) { rooms.set(roomJID, true) this.needSync = true + this.roomConfToUpdate.set(roomJID, true) } this.scheduleSync() @@ -340,12 +405,14 @@ class RoomChannel { if (rooms) { if (rooms.delete(roomJID)) { this.needSync = true + this.roomConfToUpdate.set(roomJID, true) } } } if (this.room2Channel.delete(roomJID)) { this.needSync = true + this.roomConfToUpdate.set(roomJID, true) } this.scheduleSync() @@ -364,11 +431,12 @@ class RoomChannel { const rooms = this.channel2Rooms.get(channelId) if (rooms) { - for (const jid of rooms.keys()) { + for (const roomJID 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) + if (this.room2Channel.get(roomJID) === channelId) { + this.room2Channel.delete(roomJID) this.needSync = true + this.roomConfToUpdate.set(roomJID, true) } } } @@ -414,6 +482,25 @@ class RoomChannel { return Array.from(rooms.keys()) } + /** + * Call this method when the channel configuration options changed, to refresh all files. + * @param channelId channel ID + */ + public refreshChannelConfigurationOptions (channelId: number | string): void { + channelId = parseInt(channelId.toString()) + if (isNaN(channelId)) { + this.logger.error('Invalid channelId, we wont link') + return + } + + const roomJIDs = this.getChannelRoomJIDs(channelId) + this.needSync = true + for (const roomJID of roomJIDs) { + this.roomConfToUpdate.set(roomJID, true) + } + this.scheduleSync() + } + protected _canonicJID (roomJID: string): string | null { const splits = roomJID.split('@') if (splits.length < 2) { diff --git a/server/lib/routers/api/configuration.ts b/server/lib/routers/api/configuration.ts index fed4af86..4db99d41 100644 --- a/server/lib/routers/api/configuration.ts +++ b/server/lib/routers/api/configuration.ts @@ -4,7 +4,11 @@ import type { ChannelInfos } from '../../../../shared/lib/types' import { asyncMiddleware } from '../../middlewares/async' import { getCheckConfigurationChannelMiddleware } from '../../middlewares/configuration/channel' import { checkConfigurationEnabledMiddleware } from '../../middlewares/configuration/configuration' -import { getChannelConfigurationOptions, storeChannelConfigurationOptions } from '../../configuration/channel/storage' +import { + getChannelConfigurationOptions, + getDefaultChannelConfigurationOptions, + storeChannelConfigurationOptions +} from '../../configuration/channel/storage' import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize' async function initConfigurationApiRouter (options: RegisterServerOptions, router: Router): Promise { @@ -21,7 +25,14 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route } const channelInfos = res.locals.channelInfos as ChannelInfos - const result = await getChannelConfigurationOptions(options, channelInfos) + const channelOptions = + await getChannelConfigurationOptions(options, channelInfos.id) ?? + getDefaultChannelConfigurationOptions(options) + + const result = { + channel: channelInfos, + configuration: channelOptions + } res.status(200) res.json(result) } @@ -39,9 +50,9 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route const channelInfos = res.locals.channelInfos as ChannelInfos logger.debug('Trying to save ChannelConfigurationOptions') - let configuration + let channelOptions try { - configuration = await sanitizeChannelConfigurationOptions(options, channelInfos, req.body) + channelOptions = await sanitizeChannelConfigurationOptions(options, channelInfos.id, req.body) } catch (err) { logger.warn(err) res.sendStatus(400) @@ -51,9 +62,9 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route logger.debug('Data seems ok, storing them.') const result = { channel: channelInfos, - configuration + configuration: channelOptions } - await storeChannelConfigurationOptions(options, result) + await storeChannelConfigurationOptions(options, channelInfos.id, channelOptions) res.status(200) res.json(result) } diff --git a/server/main.ts b/server/main.ts index affdc49b..f1b87b7b 100644 --- a/server/main.ts +++ b/server/main.ts @@ -73,6 +73,7 @@ async function unregister (): Promise { unloadDebugMode() await RoomChannel.destroySingleton() + await BotConfiguration.destroySingleton() const module = __filename OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`)