From 92e9d6d1afb5ab980a587ec94d71bac563151274 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Thu, 6 Jun 2024 11:36:07 +0200 Subject: [PATCH] Custom channel emoticons WIP (#130) --- client/@types/global.d.ts | 3 + .../configuration/elements/channel-emojis.ts | 9 + .../configuration/services/channel-details.ts | 12 +- .../common/lib/elements/dynamic-table-form.ts | 15 +- .../common/lib/elements/image-file-input.ts | 21 ++- client/common/lib/models/validation.ts | 1 + languages/en.yml | 5 +- server/lib/emojis/emojis.ts | 155 ++++++++++++++++-- server/lib/routers/api/configuration.ts | 9 +- shared/lib/emojis.ts | 5 + 10 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 shared/lib/emojis.ts diff --git a/client/@types/global.d.ts b/client/@types/global.d.ts index c736bd91..8127800a 100644 --- a/client/@types/global.d.ts +++ b/client/@types/global.d.ts @@ -83,9 +83,12 @@ declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FOR_MORE_INFO: string declare const LOC_VALIDATION_ERROR: string declare const LOC_INVALID_VALUE: string +declare const LOC_INVALID_VALUE_MISSING: string declare const LOC_INVALID_VALUE_WRONG_TYPE: string declare const LOC_INVALID_VALUE_WRONG_FORMAT: string declare const LOC_INVALID_VALUE_NOT_IN_RANGE: string +declare const LOC_INVALID_VALUE_FILE_TOO_BIG: string + declare const LOC_CHATROOM_NOT_ACCESSIBLE: string declare const LOC_PROMOTE: string diff --git a/client/common/configuration/elements/channel-emojis.ts b/client/common/configuration/elements/channel-emojis.ts index cfb457ac..a7f23ba4 100644 --- a/client/common/configuration/elements/channel-emojis.ts +++ b/client/common/configuration/elements/channel-emojis.ts @@ -9,6 +9,7 @@ import { LivechatElement } from '../../lib/elements/livechat' import { registerClientOptionsContext } from '../../lib/contexts/peertube' import { ChannelDetailsService } from '../services/channel-details' import { channelDetailsServiceContext } from '../contexts/channel' +import { maxEmojisPerChannel } from 'shared/lib/emojis' import { ptTr } from '../../lib/directives/translation' import { ValidationError } from '../../lib/models/validation' import { Task } from '@lit/task' @@ -78,12 +79,19 @@ export class ChannelEmojisElement extends LivechatElement { { if (this._channelEmojisConfiguration) { this._channelEmojisConfiguration.emojis.customEmojis = e.detail + // Fixing missing ':' for shortnames: + for (const desc of this._channelEmojisConfiguration.emojis.customEmojis) { + if (desc.sn === '') { continue } + if (!desc.sn.startsWith(':')) { desc.sn = ':' + desc.sn } + if (!desc.sn.endsWith(':')) { desc.sn += ':' } + } this.requestUpdate('_channelEmojisConfiguration') } } @@ -131,6 +139,7 @@ export class ChannelEmojisElement extends LivechatElement { try { await this._channelDetailsService.saveEmojisConfiguration(this.channelId, this._channelEmojisConfiguration.emojis) this._validationError = undefined + peertubeHelpers.notifier.info(await peertubeHelpers.translate(LOC_SUCCESSFULLY_SAVED)) this.requestUpdate('_validationError') } catch (error) { this._validationError = undefined diff --git a/client/common/configuration/services/channel-details.ts b/client/common/configuration/services/channel-details.ts index 2a723971..f40965a0 100644 --- a/client/common/configuration/services/channel-details.ts +++ b/client/common/configuration/services/channel-details.ts @@ -184,10 +184,16 @@ export class ChannelDetailsService { for (const [i, e] of channelEmojis.customEmojis.entries()) { propertiesError[`emojis.${i}.sn`] = [] - // FIXME: the ":" should not be in the value, but added afterward. - if (!/^:[\w-]+:$/.test(e.sn)) { + if (e.sn === '') { + propertiesError[`emojis.${i}.sn`].push(ValidationErrorType.Missing) + } else if (!/^:[\w-]+:$/.test(e.sn)) { propertiesError[`emojis.${i}.sn`].push(ValidationErrorType.WrongFormat) } + + propertiesError[`emojis.${i}.url`] = [] + if (!e.url) { + propertiesError[`emojis.${i}.url`].push(ValidationErrorType.Missing) + } } if (Object.values(propertiesError).find(e => e.length > 0)) { @@ -227,7 +233,5 @@ export class ChannelDetailsService { } throw new Error('Can\'t get channel emojis options.') } - - return response.json() } } diff --git a/client/common/lib/elements/dynamic-table-form.ts b/client/common/lib/elements/dynamic-table-form.ts index e258aa56..6ed90de5 100644 --- a/client/common/lib/elements/dynamic-table-form.ts +++ b/client/common/lib/elements/dynamic-table-form.ts @@ -6,6 +6,7 @@ import type { TagsInputElement } from './tags-input' import type { DirectiveResult } from 'lit/directive' import { ValidationErrorType } from '../models/validation' +import { maxSize, inputFileAccept } from 'shared/lib/emojis' import { html, nothing, TemplateResult } from 'lit' import { repeat } from 'lit/directives/repeat.js' import { customElement, property, state } from 'lit/decorators.js' @@ -93,6 +94,9 @@ export class DynamicTableFormElement extends LivechatElement { @property({ attribute: false }) public schema: DynamicFormSchema = {} + @property({ attribute: false }) + public maxLines?: number = undefined + @property() public validation?: {[key: string]: ValidationErrorType[] } @@ -223,6 +227,9 @@ export class DynamicTableFormElement extends LivechatElement { } private readonly _renderFooter = (): TemplateResult => { + if (this.maxLines && this._rowsById.length >= this.maxLines) { + return html`` + } return html` ${Object.values(this.header).map(() => html``)} @@ -574,7 +581,10 @@ export class DynamicTableFormElement extends LivechatElement { id=${inputId} aria-describedby="${inputId}-feedback" @change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)} - .value=${propertyValue}>` + .value=${propertyValue} + .maxSize=${maxSize} + .accept=${inputFileAccept} + >` } _getInputValidationClass = (propertyName: string, @@ -595,6 +605,9 @@ export class DynamicTableFormElement extends LivechatElement { this.validation?.[`${this.validationPrefix}.${originalIndex}.${propertyName}`] if (validationErrorTypes !== undefined && validationErrorTypes.length !== 0) { + if (validationErrorTypes.includes(ValidationErrorType.Missing)) { + errorMessages.push(html`${ptTr(LOC_INVALID_VALUE_MISSING)}`) + } if (validationErrorTypes.includes(ValidationErrorType.WrongType)) { errorMessages.push(html`${ptTr(LOC_INVALID_VALUE_WRONG_TYPE)}`) } diff --git a/client/common/lib/elements/image-file-input.ts b/client/common/lib/elements/image-file-input.ts index ec067f25..fd1d0f5e 100644 --- a/client/common/lib/elements/image-file-input.ts +++ b/client/common/lib/elements/image-file-input.ts @@ -33,6 +33,12 @@ export class ImageFileInputElement extends LivechatElement { @property({ reflect: true }) public value: string | undefined + @property({ attribute: false }) + public maxSize?: number + + @property({ attribute: false }) + public accept: string[] = ['image/jpg', 'image/png', 'image/gif'] + protected override render = (): unknown => { // FIXME: limit file size in the upload field. return html` @@ -46,7 +52,7 @@ export class ImageFileInputElement extends LivechatElement { } this._upload(ev)} @@ -65,9 +71,16 @@ export class ImageFileInputElement extends LivechatElement { const target = ev.target const file = (target as HTMLInputElement).files?.[0] if (!file) { - this.value = '' - const event = new Event('change') - this.dispatchEvent(event) + return + } + + if (this.maxSize && file.size > this.maxSize) { + let msg = await this.registerClientOptions?.peertubeHelpers.translate(LOC_INVALID_VALUE_FILE_TOO_BIG) + if (msg) { + // FIXME: better unit handling (here we force kb) + msg = msg.replace('%s', Math.round(this.maxSize / 1024).toString() + 'k') + this.registerClientOptions?.peertubeHelpers.notifier.error(msg) + } return } diff --git a/client/common/lib/models/validation.ts b/client/common/lib/models/validation.ts index 484a455a..95cee64f 100644 --- a/client/common/lib/models/validation.ts +++ b/client/common/lib/models/validation.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only export enum ValidationErrorType { + Missing, WrongType, WrongFormat, NotInRange, diff --git a/languages/en.yml b/languages/en.yml index df0c2bb4..39ea6072 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -436,9 +436,11 @@ livechat_configuration_channel_bot_nickname: "Bot nickname" validation_error: "There was an error during validation." invalid_value: "Invalid value." +invalid_value_missing: "This value is required." invalid_value_wrong_type: "Value is of the wrong type." invalid_value_wrong_format: "Value is in the wrong format." invalid_value_not_in_range: "Value is not in authorized range." +invalid_value_file_too_big: "File size is too big (max size: %s)" slow_mode_info: "Slow mode is enabled, users can send a message every %1$s seconds." @@ -483,5 +485,4 @@ livechat_emojis_shortname_desc: | The short name can only contain alphanumerical characters, underscores and hyphens. livechat_emojis_file: 'File' livechat_emojis_file_desc: | - The maximum file size must be 32px by 32px. - Accepted formats: png, jpg, gif. + The emoji file. diff --git a/server/lib/emojis/emojis.ts b/server/lib/emojis/emojis.ts index 60d41955..dc97c498 100644 --- a/server/lib/emojis/emojis.ts +++ b/server/lib/emojis/emojis.ts @@ -6,17 +6,31 @@ import type { ChannelEmojis, CustomEmojiDefinition } from '../../../shared/lib/t import { RegisterServerOptions } from '@peertube/peertube-types' import { getBaseRouterRoute } from '../helpers' import { canonicalizePluginUri } from '../uri/canonicalize' +import { allowedMimeTypes, allowedExtensions, maxEmojisPerChannel, maxSize } from '../../../shared/lib/emojis' import * as path from 'node:path' import * as fs from 'node:fs' let singleton: Emojis | undefined +interface BufferInfos { + url: string + buf: Buffer + filename: string +} + export class Emojis { protected options: RegisterServerOptions protected channelBasePath: string protected channelBaseUri: string + protected readonly logger: { + debug: (s: string) => void + info: (s: string) => void + warn: (s: string) => void + error: (s: string) => void + } constructor (options: RegisterServerOptions) { + const logger = options.peertubeHelpers.logger this.options = options this.channelBasePath = path.resolve( options.peertubeHelpers.plugin.getDataDirectoryPath(), @@ -31,6 +45,13 @@ export class Emojis { removePluginVersion: true } ) + + this.logger = { + debug: (s) => logger.debug('[Emojis] ' + s), + info: (s) => logger.info('[Emojis] ' + s), + warn: (s) => logger.warn('[Emojis] ' + s), + error: (s) => logger.error('[Emojis] ' + s) + } } /** @@ -77,7 +98,17 @@ export class Emojis { * @param fileName the filename to test */ public validImageFileName (fileName: string): boolean { - return /^(\d+)\.(png|jpg|gif)$/.test(fileName) + const m = fileName.match(/^(?:\d+)\.([a-z]+)$/) + if (!m) { + this.logger.debug('Filename invalid: ' + fileName) + return false + } + const ext = m[1] + if (!allowedExtensions.includes(ext)) { + this.logger.debug('File extension non allowed: ' + ext) + return false + } + return true } /** @@ -85,7 +116,11 @@ export class Emojis { * @param sn short name */ public validShortName (sn: any): boolean { - return (typeof sn === 'string') && /^:[\w-]+:$/.test(sn) + if ((typeof sn !== 'string') || !/^:[\w-]+:$/.test(sn)) { + this.logger.debug('Short name invalid: ' + (typeof sn === 'string' ? sn : '???')) + return false + } + return true } /** @@ -95,17 +130,68 @@ export class Emojis { * @returns true if ok */ public async validFileUrl (channelId: number, url: any): Promise { - 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) { + if (typeof url !== 'string') { + this.logger.debug('File url is not a string') return false } - // TODO: test if file exists? + if (!url.startsWith('https://') && !url.startsWith('http://')) { + this.logger.debug('Url does not start by http scheme') + return false + } + const fileName = url.split('/').pop() ?? '' + if (!this.validImageFileName(fileName)) { return false } + const correctUrl = this.channelBaseUri + channelId.toString() + '/files/' + encodeURIComponent(fileName) + if (url !== correctUrl) { + this.logger.debug('Url is not the expected url: ' + url + ' vs ' + correctUrl) + return false + } + // TODO: test if file exists? (if so, only if we dont have any buffer to write) return true } + public async validBufferInfos (channelId: number, toBufInfos: BufferInfos): Promise { + if (toBufInfos.buf.length > maxSize) { + this.logger.debug('File is too big') + return false + } + return true + } + + public async fileDataURLToBufferInfos ( + channelId: number, + url: unknown, + cnt: number + ): Promise { + if ((typeof url !== 'string') || !url.startsWith('data:')) { + return undefined + } + const regex = /^data:(\w+\/([a-z]+));base64,/ + const m = url.match(regex) + if (!m) { + this.logger.debug('Invalid data url format.') + return undefined + } + const mimetype = m[1] + if (!allowedMimeTypes.includes(mimetype)) { + this.logger.debug('Mime type not allowed: ' + mimetype) + } + const ext = m[2] + if (!allowedExtensions.includes(ext)) { + this.logger.debug('Extension not allowed: ' + ext) + return undefined + } + const buf = Buffer.from(url.replace(regex, ''), 'base64') + + // For the filename, in order to get something unique, we will just use a timestamp + a counter. + const filename = Date.now().toString() + cnt.toString() + '.' + ext + const newUrl = this.channelBaseUri + channelId.toString() + '/files/' + encodeURIComponent(filename) + return { + buf, + url: newUrl, + filename + } + } + /** * Returns the filepath for a given channel custom emojis * @param channelId channel Id @@ -134,29 +220,44 @@ export class Emojis { * Throw an error if format is not valid. * @param channelId channel id * @param def the definition - * @returns a proper ChannelEmojis + * @returns a proper ChannelEmojis, and some BufferInfos for missing files * @throws Error if format is not valid */ - public async sanitizeChannelDefinition (channelId: number, def: any): Promise { + public async sanitizeChannelDefinition (channelId: number, def: any): Promise<[ChannelEmojis, BufferInfos[]]> { 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 + if (def.customEmojis.length > maxEmojisPerChannel) { // to avoid unlimited image storage throw new Error('Too many custom emojis') } + const buffersInfos: BufferInfos[] = [] + let cnt = 0 + const customEmojis: CustomEmojiDefinition[] = [] let categoryEmojiFound = false for (const ce of def.customEmojis) { + cnt++ if (typeof ce !== 'object') { throw new Error('Invalid custom emoji') } if (!this.validShortName(ce.sn)) { throw new Error('Invalid short name') } + if ((typeof ce.url === 'string') && ce.url.startsWith('data:')) { + const b = await this.fileDataURLToBufferInfos(channelId, ce.url, cnt) + if (!b) { + throw new Error('Invalid data URL') + } + if (!await this.validBufferInfos(channelId, b)) { + throw new Error('Invalid file') + } + ce.url = b.url + buffersInfos.push(b) + } if (!await this.validFileUrl(channelId, ce.url)) { throw new Error('Invalid file url') } @@ -177,20 +278,42 @@ export class Emojis { const result: ChannelEmojis = { customEmojis: customEmojis } - return result + return [result, buffersInfos] } /** * Saves the channel custom emojis definition file. * @param channelId the channel Id * @param def the custom emojis definition + * @param bufferInfos buffers to write for missing files. */ - public async saveChannelDefinition (channelId: number, def: ChannelEmojis): Promise { + public async saveChannelDefinition ( + channelId: number, + def: ChannelEmojis, + bufferInfos: BufferInfos[] + ): Promise { const filepath = this.channelCustomEmojisDefinitionPath(channelId) - await fs.promises.mkdir(path.dirname(filepath), { - recursive: true - }) + await fs.promises.mkdir( + path.resolve( + path.dirname(filepath), + 'files' + ), + { + recursive: true + } + ) await fs.promises.writeFile(filepath, JSON.stringify(def)) + + for (const b of bufferInfos) { + const fp = path.resolve( + path.dirname(filepath), + 'files', + b.filename + ) + await fs.promises.writeFile(fp, b.buf) + } + + // TODO: remove deprecated files. } /** diff --git a/server/lib/routers/api/configuration.ts b/server/lib/routers/api/configuration.ts index e2541222..ed4e7ce9 100644 --- a/server/lib/routers/api/configuration.ts +++ b/server/lib/routers/api/configuration.ts @@ -154,16 +154,19 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route const channelInfos = res.locals.channelInfos as ChannelInfos const emojisDefinition = req.body - let emojisDefinitionSanitized + let emojisDefinitionSanitized, bufferInfos try { - emojisDefinitionSanitized = await emojis.sanitizeChannelDefinition(channelInfos.id, emojisDefinition) + [emojisDefinitionSanitized, bufferInfos] = await emojis.sanitizeChannelDefinition( + channelInfos.id, + emojisDefinition + ) } catch (err) { logger.warn(err) res.sendStatus(400) return } - await emojis.saveChannelDefinition(channelInfos.id, emojisDefinitionSanitized) + await emojis.saveChannelDefinition(channelInfos.id, emojisDefinitionSanitized, bufferInfos) res.sendStatus(200) } catch (err) { diff --git a/shared/lib/emojis.ts b/shared/lib/emojis.ts new file mode 100644 index 00000000..dce5f813 --- /dev/null +++ b/shared/lib/emojis.ts @@ -0,0 +1,5 @@ +export const maxSize: number = 20 * 1024 +export const allowedExtensions = ['png', 'jpg', 'jpeg', 'gif'] +export const inputFileAccept = ['image/jpg', 'image/png', 'image/gif'] +export const allowedMimeTypes = ['image/jpg', 'image/png', 'image/gif'] +export const maxEmojisPerChannel = 100