WIP: store and get relation between rooms and channels (refactoring)

This commit is contained in:
John Livingston 2023-09-08 20:00:14 +02:00
parent 32b52adebb
commit c900d2d1d4
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
7 changed files with 379 additions and 131 deletions

View File

@ -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<void> {
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
}

View File

@ -0,0 +1 @@
export * from './room-channel-class'

View File

@ -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<string, number> = new Map<string, number>()
protected channel2Rooms: Map<number, Map<string, true>> = new Map<number, Map<string, true>>()
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<RoomChannel> {
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<void> {
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<boolean> {
// 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<string, true>()
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<void> {
this.logger.error('rebuildData Not implemented yet')
await this.sync() // FIXME: or maybe scheduleSync ?
}
/**
* Syncs data to disk.
*/
public async sync (): Promise<void> {
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<string, true>()
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

View File

@ -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<void> {
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
}

View File

@ -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)
}

View File

@ -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<any> {
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<any> {
// 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<any> {
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<any> {
unloadDebugMode()
await RoomChannel.destroySingleton()
const module = __filename
OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`)
// Peertube calls decache(plugin) on register, not unregister.

View File

@ -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.