Custom channel emoticons WIP (#130)

This commit is contained in:
John Livingston
2024-06-04 16:39:25 +02:00
parent 607a71b8cb
commit 688ab4f029
15 changed files with 469 additions and 48 deletions

View File

@ -15,6 +15,7 @@ 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'
@ -284,10 +285,11 @@ async function _connectionInfos (
params.forcetype ?? false
)
if (!settings['disable-channel-configuration'] && video?.channelId) {
if (video?.channelId && await Emojis.singletonSafe()?.channelHasCustomEmojis(video.channelId)) {
customEmojisUrl = getBaseRouterRoute(options) +
'api/configuration/channel/emojis/' +
encodeURIComponent(video.channelId)
'emojis/channel/' +
encodeURIComponent(video.channelId) +
'/definition'
}
} catch (err) {
options.peertubeHelpers.logger.error(err)

232
server/lib/emojis/emojis.ts Normal file
View File

@ -0,0 +1,232 @@
import type { ChannelEmojis, CustomEmojiDefinition } from '../../../shared/lib/types'
import { RegisterServerOptions } from '@peertube/peertube-types'
import { getBaseRouterRoute } from '../helpers'
import { canonicalizePluginUri } from '../uri/canonicalize'
import * as path from 'node:path'
import * as fs from 'node:fs'
let singleton: Emojis | undefined
export class Emojis {
protected options: RegisterServerOptions
protected channelBasePath: string
protected channelBaseUri: string
constructor (options: RegisterServerOptions) {
this.options = options
this.channelBasePath = path.resolve(
options.peertubeHelpers.plugin.getDataDirectoryPath(),
'emojis',
'channel'
)
const baseRouterRoute = getBaseRouterRoute(options)
this.channelBaseUri = canonicalizePluginUri(
options,
baseRouterRoute + 'emojis/channel/',
{
removePluginVersion: true
}
)
}
/**
* Test if channel has custom emojis.
* @param channelId channel Id
*/
public async channelHasCustomEmojis (channelId: number): Promise<boolean> {
const filepath = this.channelCustomEmojisDefinitionPath(channelId)
return fs.promises.access(filepath, fs.constants.F_OK).then(() => true, () => false)
}
/**
* Get the file path for the channel definition JSON file (does not test if the file exists).
* @param channelId channel Id
*/
public channelCustomEmojisDefinitionPath (channelId: number): string {
if (typeof channelId !== 'number') { throw new Error('Invalid channelId') }
return path.resolve(this.channelBasePath, channelId.toString(), 'definition.json')
}
/**
* Get the current definition for the channel emojis.
* @param channelId the channel id
* @returns the custom emojis definition
*/
public async channelCustomEmojisDefinition (channelId: number): Promise<ChannelEmojis | undefined> {
const filepath = this.channelCustomEmojisDefinitionPath(channelId)
let content
try {
content = await fs.promises.readFile(filepath)
} catch (err: any) {
if (('code' in err) && err.code === 'ENOENT') {
// File does not exist, this is normal.
return undefined
}
throw err
}
return JSON.parse(content.toString())
}
/**
* Test that the filename is a valid image filename.
* Valid file name are as `42.png`: an integer, with a valid image extension.
* @param fileName the filename to test
*/
public validImageFileName (fileName: string): boolean {
return /^(\d+)\.(png|jpg|gif)$/.test(fileName)
}
/**
* Test if short name is valid.
* @param sn short name
*/
public validShortName (sn: any): boolean {
return (typeof sn === 'string') && /^:[\w-]+:$/.test(sn)
}
/**
* Test that the url is a valid file url for the given channel.
* @param channelId channel id
* @param url the url to test
* @returns true if ok
*/
public async validFileUrl (channelId: number, url: any): Promise<boolean> {
if (typeof url !== 'string') { return false }
const fileName = url.split('/').pop() ?? ''
if (!this.validImageFileName(fileName)) { return false }
const correctUrl = this.channelBaseUri + channelId.toString() + '/files/' + fileName
if (url !== correctUrl) {
return false
}
// TODO: test if file exists?
return true
}
/**
* Returns the filepath for a given channel custom emojis
* @param channelId channel Id
* @param fileName the file name
* @returns the file path
*/
public channelCustomEmojisFilePath (channelId: number, fileName: string): string {
if (typeof channelId !== 'number') { throw new Error('Invalid channelId') }
if (!this.validImageFileName(fileName)) { throw new Error('Invalid filename') }
return path.resolve(
this.channelBasePath,
channelId.toString(),
'files',
fileName
)
}
public emptyChannelDefinition (): ChannelEmojis {
return {
customEmojis: []
}
}
/**
* Sanitize the definition.
* Throw an error if format is not valid.
* @param channelId channel id
* @param def the definition
* @returns a proper ChannelEmojis
* @throws Error if format is not valid
*/
public async sanitizeChannelDefinition (channelId: number, def: any): Promise<ChannelEmojis> {
if (typeof def !== 'object') {
throw new Error('Invalid definition, type must be object')
}
if (!('customEmojis' in def) || !Array.isArray(def.customEmojis)) {
throw new Error('Invalid custom emojis entry in definition')
}
if (def.customEmojis.length > 100) { // to avoid unlimited image storage
throw new Error('Too many custom emojis')
}
const customEmojis: CustomEmojiDefinition[] = []
let categoryEmojiFound = false
for (const ce of def.customEmojis) {
if (typeof ce !== 'object') {
throw new Error('Invalid custom emoji')
}
if (!this.validShortName(ce.sn)) {
throw new Error('Invalid short name')
}
if (!await this.validFileUrl(channelId, ce.url)) {
throw new Error('Invalid file url')
}
const sanitized: CustomEmojiDefinition = {
sn: ce.sn,
url: ce.url
}
if (ce.isCategoryEmoji === true && !categoryEmojiFound) {
sanitized.isCategoryEmoji = true
categoryEmojiFound = true
}
customEmojis.push(sanitized)
}
const result: ChannelEmojis = {
customEmojis: customEmojis
}
return result
}
/**
* Saves the channel custom emojis definition file.
* @param channelId the channel Id
* @param def the custom emojis definition
*/
public async saveChannelDefinition (channelId: number, def: ChannelEmojis): Promise<void> {
const filepath = this.channelCustomEmojisDefinitionPath(channelId)
await fs.promises.mkdir(path.dirname(filepath), {
recursive: true
})
await fs.promises.writeFile(filepath, JSON.stringify(def))
}
/**
* Returns the singleton, of thrown an exception if it is not initialized yet.
* Please note that this singleton won't exist if feature is disabled.
* @returns the singleton
*/
public static singleton (): Emojis {
if (!singleton) {
throw new Error('Emojis singleton not initialized yet')
}
return singleton
}
/**
* Returns the singleton, or undefined if not initiliazed yet.
* Please note that this singleton won't exist if feature is disabled.
*/
public static singletonSafe (): Emojis | undefined {
return singleton
}
/**
* Creates the singleton, unless the feature is disabled.
* @param options Peertube server options
*/
public static async initSingleton (options: RegisterServerOptions): Promise<void> {
const disabled = await options.settingsManager.getSetting('disable-channel-configuration')
if (disabled) {
singleton = undefined
} else {
singleton = new Emojis(options)
}
}
/**
* frees the singleton
*/
public static async destroySingleton (): Promise<void> {
if (!singleton) { return }
singleton = undefined
}
}

View File

@ -0,0 +1,2 @@
import './emojis'
export * from './emojis'

View File

@ -12,13 +12,9 @@ import { isUserAdminOrModerator } from '../../helpers'
* Returns a middleware handler to get the channelInfos from the channel parameter.
* This is used in api related to channel configuration options.
* @param options Peertube server options
* @param publicPage If true, the page is considered public, and we don't test user rights.
* @returns middleware function
*/
function getCheckConfigurationChannelMiddleware (
options: RegisterServerOptions,
publicPage?: boolean
): RequestPromiseHandler {
function getCheckConfigurationChannelMiddleware (options: RegisterServerOptions): RequestPromiseHandler {
return async (req: Request, res: Response, next: NextFunction) => {
const logger = options.peertubeHelpers.logger
const channelId = req.params.channelId
@ -36,20 +32,18 @@ function getCheckConfigurationChannelMiddleware (
return
}
if (!publicPage) {
// To access this page, you must either be:
// - the channel owner,
// - an instance modo/admin
// - TODO: a channel chat moderator, as defined in this page.
if (channelInfos.ownerAccountId === currentUser.Account.id) {
logger.debug('Current user is the channel owner')
} else if (await isUserAdminOrModerator(options, res)) {
logger.debug('Current user is an instance moderator or admin')
} else {
logger.warn('Current user tries to access a channel for which he has no right.')
res.sendStatus(403)
return
}
// To access this page, you must either be:
// - the channel owner,
// - an instance modo/admin
// - TODO: a channel chat moderator, as defined in this page.
if (channelInfos.ownerAccountId === currentUser.Account.id) {
logger.debug('Current user is the channel owner')
} else if (await isUserAdminOrModerator(options, res)) {
logger.debug('Current user is an instance moderator or admin')
} else {
logger.warn('Current user tries to access a channel for which he has no right.')
res.sendStatus(403)
return
}
logger.debug('User can access the configuration channel api.')

View File

@ -13,6 +13,7 @@ import { initAuthApiRouter, initUserAuthApiRouter } from './api/auth'
import { initFederationServerInfosApiRouter } from './api/federation-server-infos'
import { initConfigurationApiRouter } from './api/configuration'
import { initPromoteApiRouter } from './api/promote'
import { initEmojisRouter } from './emojis'
/**
* Initiate API routes
@ -42,6 +43,7 @@ async function initApiRouter (options: RegisterServerOptions): Promise<Router> {
await initConfigurationApiRouter(options, router)
await initPromoteApiRouter(options, router)
await initEmojisRouter(options, router)
if (isDebugMode(options)) {
// Only add this route if the debug mode is enabled at time of the server launch.

View File

@ -4,7 +4,7 @@
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Router, Request, Response, NextFunction } from 'express'
import type { ChannelInfos, ChannelEmojis } from '../../../../shared/lib/types'
import type { ChannelConfiguration, ChannelEmojisConfiguration, ChannelInfos } from '../../../../shared/lib/types'
import { asyncMiddleware } from '../../middlewares/async'
import { getCheckConfigurationChannelMiddleware } from '../../middlewares/configuration/channel'
import { checkConfigurationEnabledMiddleware } from '../../middlewares/configuration/configuration'
@ -15,6 +15,7 @@ import {
} from '../../configuration/channel/storage'
import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize'
import { getConverseJSParams } from '../../../lib/conversejs/params'
import { Emojis } from '../../../lib/emojis'
async function initConfigurationApiRouter (options: RegisterServerOptions, router: Router): Promise<void> {
const logger = options.peertubeHelpers.logger
@ -57,7 +58,7 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
await getChannelConfigurationOptions(options, channelInfos.id) ??
getDefaultChannelConfigurationOptions(options)
const result = {
const result: ChannelConfiguration = {
channel: channelInfos,
configuration: channelOptions
}
@ -101,7 +102,7 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
}
logger.debug('Data seems ok, storing them.')
const result = {
const result: ChannelConfiguration = {
channel: channelInfos,
configuration: channelOptions
}
@ -113,25 +114,62 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
router.get('/configuration/channel/emojis/:channelId', asyncMiddleware([
checkConfigurationEnabledMiddleware(options),
getCheckConfigurationChannelMiddleware(options, true),
getCheckConfigurationChannelMiddleware(options),
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
if (!res.locals.channelInfos) {
logger.error('Missing channelInfos in res.locals, should not happen')
try {
if (!res.locals.channelInfos) {
throw new Error('Missing channelInfos in res.locals, should not happen')
}
const emojis = Emojis.singleton()
const channelInfos = res.locals.channelInfos as ChannelInfos
const channelEmojis =
(await emojis.channelCustomEmojisDefinition(channelInfos.id)) ??
emojis.emptyChannelDefinition()
const result: ChannelEmojisConfiguration = {
channel: channelInfos,
emojis: channelEmojis
}
res.status(200)
res.json(result)
} catch (err) {
logger.error(err)
res.sendStatus(500)
return
}
// const channelInfos = res.locals.channelInfos as ChannelInfos
}
]))
const channelEmojis: ChannelEmojis = {
customEmojis: [{
sn: ':test:',
url: '/dist/images/custom_emojis/xmpp.png',
isCategoryEmoji: true
}]
router.post('/configuration/channel/emojis/:channelId', asyncMiddleware([
checkConfigurationEnabledMiddleware(options),
getCheckConfigurationChannelMiddleware(options),
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
if (!res.locals.channelInfos) {
throw new Error('Missing channelInfos in res.locals, should not happen')
}
const emojis = Emojis.singleton()
const channelInfos = res.locals.channelInfos as ChannelInfos
const emojisDefinition = req.body
let emojisDefinitionSanitized
try {
emojisDefinitionSanitized = await emojis.sanitizeChannelDefinition(channelInfos.id, emojisDefinition)
} catch (err) {
logger.warn(err)
res.sendStatus(400)
return
}
await emojis.saveChannelDefinition(channelInfos.id, emojisDefinitionSanitized)
res.sendStatus(200)
} catch (err) {
logger.error(err)
res.sendStatus(500)
}
res.status(200)
res.json(channelEmojis)
}
]))
}

View File

@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Router, Request, Response, NextFunction } from 'express'
import { asyncMiddleware } from '../middlewares/async'
import { Emojis } from '../emojis'
export async function initEmojisRouter (
options: RegisterServerOptions,
router: Router
): Promise<void> {
const logger = options.peertubeHelpers.logger
router.get(
'/emojis/channel/:channelId/definition',
asyncMiddleware(async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const emojis = Emojis.singletonSafe()
if (!emojis) {
res.sendStatus(404)
return
}
const channelId = parseInt(req.params.channelId)
if (!channelId || isNaN(channelId)) {
res.sendStatus(400)
return
}
if (!await emojis.channelHasCustomEmojis(channelId)) {
res.sendStatus(404)
return
}
res.sendFile(emojis.channelCustomEmojisDefinitionPath(channelId))
} catch (err) {
logger.error(err)
res.sendStatus(500)
}
})
)
router.get(
'/emojis/channel/:channelId/files/:fileName',
asyncMiddleware(async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const emojis = Emojis.singletonSafe()
if (!emojis) {
res.sendStatus(404)
return
}
const channelId = parseInt(req.params.channelId)
if (!channelId || isNaN(channelId)) {
res.sendStatus(400)
return
}
const fileName = req.params.fileName
if (!emojis.validImageFileName(fileName)) {
res.sendStatus(400)
return
}
if (!await emojis.channelHasCustomEmojis(channelId)) {
res.sendStatus(404)
return
}
res.sendFile(
emojis.channelCustomEmojisFilePath(channelId, fileName),
{
immutable: true,
maxAge: 1000 * 60 * 60 * 24 // 24h
},
(err) => {
if (err) {
res.sendStatus(404)
}
}
)
} catch (err) {
logger.error(err)
res.sendStatus(500)
}
})
)
}

View File

@ -8,6 +8,7 @@ import { ensureProsodyRunning } from './prosody/ctl'
import { RoomChannel } from './room-channel'
import { BotsCtl } from './bots/ctl'
import { ExternalAuthOIDC, ExternalAuthOIDCType } from './external-auth/oidc'
import { Emojis } from './emojis'
import { loc } from './loc'
const escapeHTML = require('escape-html')
@ -68,6 +69,10 @@ async function initSettings (options: RegisterServerOptions): Promise<void> {
await ExternalAuthOIDC.initSingletons(options)
// recreating a Emojis singleton
await Emojis.destroySingleton()
await Emojis.initSingleton(options)
peertubeHelpers.logger.info('Saving settings, ensuring prosody is running')
await ensureProsodyRunning(options)

View File

@ -18,6 +18,7 @@ import { BotConfiguration } from './lib/configuration/bot'
import { BotsCtl } from './lib/bots/ctl'
import { ExternalAuthOIDC } from './lib/external-auth/oidc'
import { migrateMUCAffiliations } from './lib/prosody/migration/migrateV10'
import { Emojis } from './lib/emojis'
import decache from 'decache'
// FIXME: Peertube unregister don't have any parameter.
@ -48,6 +49,9 @@ async function register (options: RegisterServerOptions): Promise<any> {
await migrateSettings(options)
await initSettings(options)
await Emojis.initSingleton(options) // after settings, before routes
await initCustomFields(options)
await initRouters(options)
await initFederation(options)
@ -110,6 +114,7 @@ async function unregister (): Promise<any> {
await RoomChannel.destroySingleton()
await BotConfiguration.destroySingleton()
await ExternalAuthOIDC.destroySingletons()
await Emojis.destroySingleton()
const module = __filename
OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`)