From 88467fcee43f5ef9d3e7fe98c638ac25e82badb4 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Sun, 29 Nov 2020 13:35:45 +0200 Subject: [PATCH] Plugin and repository name validation, plugin restarting, optional formatting class for protocols --- src/common/time.ts | 31 +++++ src/plugin/loader.ts | 2 +- src/plugin/manager.ts | 85 ++++++++++-- src/plugin/repository/manager.ts | 9 ++ src/types/index.ts | 1 + src/types/message-format.ts | 213 +++++++++++++++++++++++++++++++ src/types/message.ts | 19 +++ src/types/protocol.ts | 9 +- 8 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 src/types/message-format.ts diff --git a/src/common/time.ts b/src/common/time.ts index 5b41ef8..29cad1e 100644 --- a/src/common/time.ts +++ b/src/common/time.ts @@ -114,3 +114,34 @@ export function thousandsSeparator(input: number | string): string { return x1 + x2; } + +export function timeSince(date: number): string { + const seconds = Math.floor((Date.now() - date) / 1000); + let interval = Math.floor(seconds / 31536000); + + if (interval > 1) { + return interval + ' years'; + } + + interval = Math.floor(seconds / 2592000); + if (interval > 1) { + return interval + ' months'; + } + + interval = Math.floor(seconds / 86400); + if (interval > 1) { + return interval + ' days'; + } + + interval = Math.floor(seconds / 3600); + if (interval > 1) { + return interval + ' hours'; + } + + interval = Math.floor(seconds / 60); + if (interval > 1) { + return interval + ' minutes'; + } + + return Math.floor(seconds) + ' seconds'; +} diff --git a/src/plugin/loader.ts b/src/plugin/loader.ts index 44e1d4e..fb7c3f6 100644 --- a/src/plugin/loader.ts +++ b/src/plugin/loader.ts @@ -28,7 +28,7 @@ export class PluginMetaLoader { throw new Error('Plugin metadata does not specify a name, for some reason'); } - if (json.name === 'squeebot') { + if (json.name === 'squeebot' || !(/^[a-zA-Z0-9_\-]+$/.test(json.name))) { throw new Error('Illegal name.'); } diff --git a/src/plugin/manager.ts b/src/plugin/manager.ts index e827379..048c684 100644 --- a/src/plugin/manager.ts +++ b/src/plugin/manager.ts @@ -23,6 +23,8 @@ export function requireNoCache(file: string): object | null { export class PluginManager { private plugins: Map = new Map(); private configs: PluginConfigurator = new PluginConfigurator(this.environment); + private restartQueue: Map = new Map(); + private stopping = false; constructor( public availablePlugins: IPluginManifest[], @@ -72,6 +74,10 @@ export class PluginManager { return false; } + if (!(/^[a-zA-Z0-9_\-]+$/.test(manifest.name))) { + throw new Error('Illegal name for a plugin!'); + } + this.availablePlugins.push(manifest); return true; } @@ -112,28 +118,53 @@ export class PluginManager { } public async load(plugin: IPluginManifest): Promise { + // Ignore loading when we're shutting down + if (this.stopping) { + throw new Error('Squeebot is shutting down'); + } + // Don't load plugins twice const ready = this.getLoadedByName(plugin.name); if (ready) { return ready; } - // Check dependencies + // Dependencies required to load const requires = []; + + // Dependencies available + const available = []; + logger.debug('Loading plugin', plugin.name); - for (const dep of plugin.dependencies) { + + // Check dependencies + for (let dep of plugin.dependencies) { + let optional = false; + if (dep.indexOf('?') !== -1) { + dep = dep.replace('?', ''); + optional = true; + } + if (dep === plugin.name) { throw new Error(`Plugin "${plugin.name}" cannot depend on itself.`); } const existing = this.getLoadedByName(dep); - if (!existing) { - const available = this.getAvailableByName(dep); - if (!available) { - throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`); - } - requires.push(available); + if (existing) { + available.push(existing.manifest); + continue; } + + const isLoaded = this.getAvailableByName(dep); + if (!isLoaded) { + if (optional) { + continue; + } + + throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`); + } + + requires.push(isLoaded); } // Load dependencies @@ -141,6 +172,7 @@ export class PluginManager { for (const manifest of requires) { try { await this.load(manifest); + available.push(manifest); } catch (e) { logger.error(e.stack); throw new Error(`Plugin dependency "${manifest.name}" loading failed for "${plugin.name}"`); @@ -216,13 +248,36 @@ export class PluginManager { this.stream.emit('pluginLoaded', loaded); // Inform the new plugin that it's dependencies are available - for (const depn of plugin.dependencies) { - this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn)); + for (const depn of available) { + this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn.name)); } return loaded; } + public async restart(mf: IPluginManifest | IPlugin | string): Promise { + let manifest; + if (typeof mf === 'string') { + manifest = this.getAvailableByName(mf); + } else if ('manifest' in mf) { + manifest = mf.manifest; + } else { + manifest = mf; + } + + if (!manifest) { + throw new Error('Plugin not found'); + } + + if (!this.getLoadedByName(manifest.name)) { + this.load(manifest); + return; + } + + this.restartQueue.set(manifest.name, manifest); + this.stream.emitTo(manifest.name, 'pluginUnload', manifest.name); + } + private addEvents(): void { this.stream.on('core', 'pluginLoad', (mf: IPluginManifest | string) => { if (typeof mf === 'string') { @@ -253,6 +308,13 @@ export class PluginManager { // Remove all listeners created by the plugin this.stream.removeName(mf); + + // Restart, if applicable + if (this.restartQueue.has(mf) && !this.stopping) { + const manifest = this.restartQueue.get(mf) as IPluginManifest; + this.restartQueue.delete(mf); + this.load(manifest).catch(e => console.error(e)); + } }); this.stream.on('core', 'pluginKill', (mf: IPlugin | string) => { @@ -271,6 +333,9 @@ export class PluginManager { return; } + // Prevent loading of new plugins + this.stopping = true; + logger.debug('Shutdown has been received by plugin manager'); // Shutting down all the plugins diff --git a/src/plugin/repository/manager.ts b/src/plugin/repository/manager.ts index c1687c6..c8b292c 100644 --- a/src/plugin/repository/manager.ts +++ b/src/plugin/repository/manager.ts @@ -135,6 +135,10 @@ export class RepositoryManager { throw new Error('Invalid metadata file for repository.'); } + if (!(/^[a-zA-Z0-9_\-\+]+$/.test(meta.name))) { + throw new Error('Illegal name for repository!'); + } + if (meta.schema > 1) { throw new Error('Unsupported metadata version!'); } @@ -228,6 +232,11 @@ export class RepositoryManager { if (!contents.name || !contents.url || !contents.plugins) { throw new Error('Invalid repository file ' + rf); } + + if (!(/^[a-zA-Z0-9_\-\+]+$/.test(contents.name))) { + throw new Error(`"${rf}" is an illegal name for a repository!`); + } + loaded.push(contents.name); this.repositories.set(contents.name, contents); } catch (e) { diff --git a/src/types/index.ts b/src/types/index.ts index 1be031d..a1edc47 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,5 +2,6 @@ export * from './config'; export * from './environment'; export * from './plugin-config'; export * from './message'; +export * from './message-format'; export * from './protocol'; export * from './service'; diff --git a/src/types/message-format.ts b/src/types/message-format.ts new file mode 100644 index 0000000..8fc1371 --- /dev/null +++ b/src/types/message-format.ts @@ -0,0 +1,213 @@ +/* Recommended usage + 16-bit colors: + black + darkblue + green + red + brown + purple + gold + yellow + limegreen + cyan + lightblue + blue + pink + darkgray + gray + white + or any hex value prepended with # + + Formats: + bold + italic + emphasis + strike + underline +*/ + +import { thousandsSeparator, timeSince, toHHMMSS } from '../common'; + +// Color Utilities + +export function colorDistance(s: string, t: string): number { + if (!s.length || !t.length) { + return 0; + } + + return colorDistance(s.slice(2), t.slice(2)) + + Math.abs(parseInt(s.slice(0, 2), 16) - parseInt(t.slice(0, 2), 16)); +} + +export function approximateB16Color(cl: string): string { + const arr = []; + let closest = 'white'; + + for (const i in b16Colors) { + arr.push(b16Colors[i].substring(1)); + } + + cl = cl.substring(1); + + arr.sort((a, b) => { + return colorDistance(a, cl) - colorDistance(b, cl); + }); + + for (const i in b16Colors) { + if (b16Colors[i] === '#' + arr[0]) { + closest = i; + } + } + + return closest; +} + +export function b16toHex(name: string): string { + if (!b16Colors[name]) { + return '#000000'; + } + + return b16Colors[name]; +} + +interface IMethod { + start: string; + end: string; +} + +declare type Keyed = {[key: string]: string}; +declare type Method = {[key: string]: IMethod}; + +const b16Colors: Keyed = { + black: '#000000', + darkblue: '#00007f', + green: '#009300', + red: '#ff0000', + brown: '#7f0000', + purple: '#9c009c', + gold: '#fc7f00', + yellow: '#ffff00', + limegreen: '#00fc00', + cyan: '#00ffff', + lightblue: '#0000fc', + blue: '#009393', + pink: '#ff00ff', + darkgray: '#7f7f7f', + gray: '#d2d2d2', + white: '#ffffff' +}; + +export class Formatter { + public colors: Keyed = {}; + public formatting: Method = {}; + public colorEscape = ''; + constructor(public supportFormatting = false, public supportColors = false) {} + + public color(color: string, msg: string): string { + return msg; + } + + public format(method: string, msg: string): string { + if (!this.supportFormatting) { + return msg; + } + + if (!this.formatting[method]) { + return msg; + } + + if (this.formatting[method].start && this.formatting[method].end) { + return this.formatting[method].start + msg + this.formatting[method].end; + } else { + return this.formatting[method] + msg + this.formatting[method]; + } + } + + public strip(msg: string): string { + return msg; + } + + // Object compositor. + // This default function turns objects into plain text without any formatting. + // Override this in your protocol for the desired effect. + /* + [ + ['element type', 'text', { param: value }], + ... + ] + + Element types: + field - A field type + Parameters: + * label - Label for this field. If an array, the first item is considered an "icon" and wont have ':' appended to it. + * type - The type of the field. + title - A title field + description - Descriptive field + metric - A Number value. Requires integer, will be transformed into xxx,xxx,xxx + time - Time value. Requires UNIX timestamp. + timesince - Time since value. Requires UNIX timestamp, will be transformed into x seconds/minutes/hours/days ago + duration - Duration value. Requires integer, will be transformed into HH:MM:SS + content - Full message body. + * color - The color of the field. Not always supported. + bold/b/strong - Bold text + i/italic - Italic text + color - A colored text. Not always supported. + url - An URL. + Parameters: + * label - Label for this URL + image - An Image. + */ + + public compose(objs: any): any { + const str = []; + + for (const i in objs) { + const elem = objs[i]; + + const elemType = elem[0]; + let elemValue = elem[1]; + const elemParams = elem[2]; + + if (!elemValue) { + continue; + } + + // Special types + if (elemParams && elemParams.type) { + switch (elemParams.type) { + case 'time': + elemValue = new Date(elemValue).toString(); + break; + case 'metric': + elemValue = thousandsSeparator(elemValue); + break; + case 'timesince': + elemValue = timeSince(elemValue); + break; + case 'duration': + elemValue = toHHMMSS(elemValue); + break; + } + } + + if (elemParams && elemParams.label) { + let label = elemParams.label; + + // If the label param is an array, choose the last element + // The last element is generally the text version, as opposed to + // the first element being an icon. + if (typeof label === 'object') { + label = elemParams.label[elemParams.label.length - 1]; + } + + str.push(elemParams.label + ': ' + elemValue); + } else { + str.push(elemValue); + } + } + + // May return an object, but your protocol must support it. + return str.join(' '); + } +} + diff --git a/src/types/message.ts b/src/types/message.ts index 98f499f..ba76b13 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -3,9 +3,28 @@ import { Protocol } from './protocol'; // TODO: Source specification to support plugin services. +export enum EMessageType { + message = 0, + roomJoin = 1, + roomLeave = 2, + roomKick = 3, + nameChange = 4, + edit = 5, +} + +export interface IMessageTarget { + id: string; + name: string; +} + export interface IMessage { + type: EMessageType; data: any; source: IPlugin | Protocol; + guest?: boolean; + target?: IMessageTarget; + sender?: IMessageTarget; time: Date; + resolved: boolean; resolve(...args: any[]): void; } diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 0a68a77..4adb07a 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -3,10 +3,14 @@ import { randomBytes } from 'crypto'; import { EventEmitter } from 'events'; import { IPlugin } from '../plugin'; import { IMessage } from './message'; +import { Formatter } from './message-format'; export class Protocol extends EventEmitter { - // override this! + public format: Formatter = new Formatter(false, false); + public id = randomBytes(4).toString('hex'); + + // override this! public type = 'GenericProtocol'; protected running = false; @@ -52,6 +56,9 @@ export class Protocol extends EventEmitter { } public resolve(message: IMessage, ...data: any[]): void {} + public get fullName(): string { + return this.plugin.manifest.name + '/' + this.name; + } protected passEvents(): void { this.on('stop', (force) => this.stop(force));