Plugin and repository name validation, plugin restarting, optional formatting class for protocols

This commit is contained in:
Evert Prants 2020-11-29 13:35:45 +02:00
parent a86b912520
commit 88467fcee4
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
8 changed files with 357 additions and 12 deletions

View File

@ -114,3 +114,34 @@ export function thousandsSeparator(input: number | string): string {
return x1 + x2; 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';
}

View File

@ -28,7 +28,7 @@ export class PluginMetaLoader {
throw new Error('Plugin metadata does not specify a name, for some reason'); 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.'); throw new Error('Illegal name.');
} }

View File

@ -23,6 +23,8 @@ export function requireNoCache(file: string): object | null {
export class PluginManager { export class PluginManager {
private plugins: Map<string, IPlugin> = new Map(); private plugins: Map<string, IPlugin> = new Map();
private configs: PluginConfigurator = new PluginConfigurator(this.environment); private configs: PluginConfigurator = new PluginConfigurator(this.environment);
private restartQueue: Map<string, IPluginManifest> = new Map();
private stopping = false;
constructor( constructor(
public availablePlugins: IPluginManifest[], public availablePlugins: IPluginManifest[],
@ -72,6 +74,10 @@ export class PluginManager {
return false; return false;
} }
if (!(/^[a-zA-Z0-9_\-]+$/.test(manifest.name))) {
throw new Error('Illegal name for a plugin!');
}
this.availablePlugins.push(manifest); this.availablePlugins.push(manifest);
return true; return true;
} }
@ -112,28 +118,53 @@ export class PluginManager {
} }
public async load(plugin: IPluginManifest): Promise<IPlugin> { public async load(plugin: IPluginManifest): Promise<IPlugin> {
// Ignore loading when we're shutting down
if (this.stopping) {
throw new Error('Squeebot is shutting down');
}
// Don't load plugins twice // Don't load plugins twice
const ready = this.getLoadedByName(plugin.name); const ready = this.getLoadedByName(plugin.name);
if (ready) { if (ready) {
return ready; return ready;
} }
// Check dependencies // Dependencies required to load
const requires = []; const requires = [];
// Dependencies available
const available = [];
logger.debug('Loading plugin', plugin.name); 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) { if (dep === plugin.name) {
throw new Error(`Plugin "${plugin.name}" cannot depend on itself.`); throw new Error(`Plugin "${plugin.name}" cannot depend on itself.`);
} }
const existing = this.getLoadedByName(dep); const existing = this.getLoadedByName(dep);
if (!existing) { if (existing) {
const available = this.getAvailableByName(dep); available.push(existing.manifest);
if (!available) { continue;
}
const isLoaded = this.getAvailableByName(dep);
if (!isLoaded) {
if (optional) {
continue;
}
throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`); throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`);
} }
requires.push(available);
} requires.push(isLoaded);
} }
// Load dependencies // Load dependencies
@ -141,6 +172,7 @@ export class PluginManager {
for (const manifest of requires) { for (const manifest of requires) {
try { try {
await this.load(manifest); await this.load(manifest);
available.push(manifest);
} catch (e) { } catch (e) {
logger.error(e.stack); logger.error(e.stack);
throw new Error(`Plugin dependency "${manifest.name}" loading failed for "${plugin.name}"`); throw new Error(`Plugin dependency "${manifest.name}" loading failed for "${plugin.name}"`);
@ -216,13 +248,36 @@ export class PluginManager {
this.stream.emit('pluginLoaded', loaded); this.stream.emit('pluginLoaded', loaded);
// Inform the new plugin that it's dependencies are available // Inform the new plugin that it's dependencies are available
for (const depn of plugin.dependencies) { for (const depn of available) {
this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn)); this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn.name));
} }
return loaded; return loaded;
} }
public async restart(mf: IPluginManifest | IPlugin | string): Promise<void> {
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 { private addEvents(): void {
this.stream.on('core', 'pluginLoad', (mf: IPluginManifest | string) => { this.stream.on('core', 'pluginLoad', (mf: IPluginManifest | string) => {
if (typeof mf === 'string') { if (typeof mf === 'string') {
@ -253,6 +308,13 @@ export class PluginManager {
// Remove all listeners created by the plugin // Remove all listeners created by the plugin
this.stream.removeName(mf); 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) => { this.stream.on('core', 'pluginKill', (mf: IPlugin | string) => {
@ -271,6 +333,9 @@ export class PluginManager {
return; return;
} }
// Prevent loading of new plugins
this.stopping = true;
logger.debug('Shutdown has been received by plugin manager'); logger.debug('Shutdown has been received by plugin manager');
// Shutting down all the plugins // Shutting down all the plugins

View File

@ -135,6 +135,10 @@ export class RepositoryManager {
throw new Error('Invalid metadata file for repository.'); 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) { if (meta.schema > 1) {
throw new Error('Unsupported metadata version!'); throw new Error('Unsupported metadata version!');
} }
@ -228,6 +232,11 @@ export class RepositoryManager {
if (!contents.name || !contents.url || !contents.plugins) { if (!contents.name || !contents.url || !contents.plugins) {
throw new Error('Invalid repository file ' + rf); 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); loaded.push(contents.name);
this.repositories.set(contents.name, contents); this.repositories.set(contents.name, contents);
} catch (e) { } catch (e) {

View File

@ -2,5 +2,6 @@ export * from './config';
export * from './environment'; export * from './environment';
export * from './plugin-config'; export * from './plugin-config';
export * from './message'; export * from './message';
export * from './message-format';
export * from './protocol'; export * from './protocol';
export * from './service'; export * from './service';

213
src/types/message-format.ts Normal file
View File

@ -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(' ');
}
}

View File

@ -3,9 +3,28 @@ import { Protocol } from './protocol';
// TODO: Source specification to support plugin services. // 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 { export interface IMessage {
type: EMessageType;
data: any; data: any;
source: IPlugin | Protocol; source: IPlugin | Protocol;
guest?: boolean;
target?: IMessageTarget;
sender?: IMessageTarget;
time: Date; time: Date;
resolved: boolean;
resolve(...args: any[]): void; resolve(...args: any[]): void;
} }

View File

@ -3,10 +3,14 @@ import { randomBytes } from 'crypto';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { IPlugin } from '../plugin'; import { IPlugin } from '../plugin';
import { IMessage } from './message'; import { IMessage } from './message';
import { Formatter } from './message-format';
export class Protocol extends EventEmitter { export class Protocol extends EventEmitter {
// override this! public format: Formatter = new Formatter(false, false);
public id = randomBytes(4).toString('hex'); public id = randomBytes(4).toString('hex');
// override this!
public type = 'GenericProtocol'; public type = 'GenericProtocol';
protected running = false; protected running = false;
@ -52,6 +56,9 @@ export class Protocol extends EventEmitter {
} }
public resolve(message: IMessage, ...data: any[]): void {} public resolve(message: IMessage, ...data: any[]): void {}
public get fullName(): string {
return this.plugin.manifest.name + '/' + this.name;
}
protected passEvents(): void { protected passEvents(): void {
this.on('stop', (force) => this.stop(force)); this.on('stop', (force) => this.stop(force));