diff --git a/backend/UserConfig.ts b/backend/UserConfig.ts index a6b90ef..ed74a70 100644 --- a/backend/UserConfig.ts +++ b/backend/UserConfig.ts @@ -3,6 +3,9 @@ import { UserConfiguration, UserConfigTypeChecker, PostgresConfiguration, MongoD import fs from 'fs-extra'; import { path } from '@tycrek/joint'; import { log } from './log'; +import { prepareTemplate } from './templates/parser'; +import { TemplateError } from './templates/error'; +import { DEFAULT_EMBED, validateEmbed } from './embed'; const FILEPATH = path.join('.ass-data/userconfig.json'); @@ -130,6 +133,28 @@ export class UserConfig { if (!Checkers.rateLimit.endpoint(config.rateLimit.api)) throw new Error('Invalid API rate limit configuration'); } + // * the embed + if (config.embed != null) { + try { + for (let part of ['title', 'description', 'sitename'] as ('title' | 'description' | 'sitename')[]) { + if (config.embed[part] != null) { + if (typeof config.embed[part] == 'string') { + config.embed[part] = prepareTemplate(config.embed[part] as string); + } else throw new Error(`Template string for embed ${part} is not a string`); + } else config.embed[part] = DEFAULT_EMBED[part]; + } + + validateEmbed(config.embed); + } catch (err) { + if (err instanceof TemplateError) { + // tlog messes up the formatting + console.error(err.format()); + + throw new Error('Template error'); + } else throw err; + } + } else config.embed = DEFAULT_EMBED; + // All is fine, carry on! return config; } diff --git a/backend/embed.ts b/backend/embed.ts index 27e70a7..9828586 100644 --- a/backend/embed.ts +++ b/backend/embed.ts @@ -1,4 +1,7 @@ -import { AssFile, AssUser, EmbedTemplate, EmbedTemplateOperation, PreparedEmbed } from "ass" +import { AssFile, AssUser, EmbedTemplate, PreparedEmbed } from "ass" +import { TemplateExecutor } from "./templates/executor"; + +let executor = TemplateExecutor.createExecutor(); export const DEFAULT_EMBED: EmbedTemplate = { sitename: "ass", @@ -6,52 +9,23 @@ export const DEFAULT_EMBED: EmbedTemplate = { description: "" }; -class EmbedContext { - public uploader: AssUser; - public file: AssFile; +// ensures a template is valid +export const validateEmbed = (template: EmbedTemplate) => { + // lets hope this works + let context = executor.createContext(null!, null!); - constructor(uploader: AssUser, file: AssFile) { - this.uploader = uploader; - this.file = file; - } + executor.validateTemplate(template.title, context); + executor.validateTemplate(template.description, context); + executor.validateTemplate(template.sitename, context); } -const executeEmbedOperation = (op: EmbedTemplateOperation, ctx: EmbedContext): string | number => { - if (typeof op == 'string') { - return op; - } else if (typeof op == 'number') { - return op; - } else if (typeof op == 'object') { - switch (op.op) { - case 'random': - if (op.values.length > 0) { - return executeEmbedOperation(op.values[Math.round(Math.random() * (op.values.length - 1))], ctx); - } else throw new Error('Random without child operations'); - case 'fileSize': - return ctx.file.size; - case 'uploader': - return ctx.uploader.username; - case 'formatBytes': - // calculate the value - let value = executeEmbedOperation(op.value, ctx); - - // calculate the exponent - let exponent = (op.unit != null && { 'b': 0, 'kb': 1, 'mb': 2, 'gb': 3, 'tb': 4 }[executeEmbedOperation(op.unit, ctx)]) - || Math.max(Math.min(Math.floor(Math.log10(Number(value))/3), 4), 0); - - return `${(Number(value) / 1000 ** exponent).toFixed(2)}${['b', 'kb', 'mb', 'gb', 'tb'][exponent]}`; - case 'concat': - return op.values.reduce((prev, op) => prev.concat(executeEmbedOperation(op, ctx).toString()), ""); - } - } else throw new Error("Invalid embed template operation"); -}; - +// cooks up the embed export const prepareEmbed = (template: EmbedTemplate, user: AssUser, file: AssFile): PreparedEmbed => { - let ctx = new EmbedContext(user, file); + let context = executor.createContext(user, file); return { - title: executeEmbedOperation(template.title, ctx).toString(), - description: executeEmbedOperation(template.description, ctx).toString(), - sitename: executeEmbedOperation(template.sitename, ctx).toString() + title: executor.executeTemplate(template.title, context), + description: executor.executeTemplate(template.description, context), + sitename: executor.executeTemplate(template.sitename, context) }; }; \ No newline at end of file diff --git a/backend/templates/command.ts b/backend/templates/command.ts new file mode 100644 index 0000000..8e44a1b --- /dev/null +++ b/backend/templates/command.ts @@ -0,0 +1,9 @@ +import { TemplateCommandOp, TemplateCommandSchema } from 'ass'; +import { TemplateContext } from './executor'; + +export type TemplateCommand = { + readonly name: N; + readonly schema: S; + + exec: (op: TemplateCommandOp, ctx: TemplateContext) => string; +}; \ No newline at end of file diff --git a/backend/templates/error.ts b/backend/templates/error.ts new file mode 100644 index 0000000..28c7c4b --- /dev/null +++ b/backend/templates/error.ts @@ -0,0 +1,28 @@ +import { TemplateSourceRange } from "ass"; + +export class TemplateError extends Error { + range?: TemplateSourceRange; + + constructor(msg: string, range?: TemplateSourceRange) { + super(msg); + + this.range = range; + } + + public format(): string { + let format = ''; + + if (this.range) { + format += this.range.file.code + '\n'; + format += ' '.repeat(this.range.from) + '^' + '~'.repeat(Math.max(this.range.to - this.range.from, 0)) + '\n'; + } + + format += `${this.name}: ${this.message}`; + + return format; + } +} + +// template syntax error with token range, token range converted to source position +// outside of prepareTemplate +export class TemplateSyntaxError extends TemplateError {}; \ No newline at end of file diff --git a/backend/templates/executor.ts b/backend/templates/executor.ts new file mode 100644 index 0000000..314b12c --- /dev/null +++ b/backend/templates/executor.ts @@ -0,0 +1,121 @@ +import { AssFile, AssUser, TemplateCommandOp, TemplateCommandSchema, TemplateOp } from 'ass'; +import { TemplateCommand } from './command'; +import { TemplateError } from './error'; + +export class TemplateContext { + public readonly owner: TemplateExecutor; + + public uploader: AssUser; + public file: AssFile; + + constructor(owner: TemplateExecutor, uploader: AssUser, file: AssFile) { + this.owner = owner; + this.uploader = uploader; + this.file = file; + } +} + +export class TemplateExecutor { + private commands: { [index: string]: TemplateCommand } = {}; + + // register a template command globally + public registerCommand(name: N, attrs: S, cmd: (op: TemplateCommandOp, ctx: TemplateContext) => string) { + if (this.commands[name] == null) { + this.commands[name] = { + name: name, + schema: attrs, + exec: cmd + }; + } else throw new Error(`Template command "${name}" already exists`); + } + + public createContext(uploader: AssUser, file: AssFile) { + return new TemplateContext(this, uploader, file); + } + + // expects template to be valid and does not preform runtime checks. + // run validateTemplate first + public executeTemplate(op: TemplateOp, ctx: TemplateContext): string { + switch (typeof op) { + case 'string': return op; + case 'object': return this.commands[op.op].exec(op, ctx); + } + } + + public validateTemplate(op: TemplateOp, ctx: TemplateContext): void { + if (typeof op == 'string') return; + + if (this.commands[op.op] != null) { + let cmd = this.commands[op.op].schema as TemplateCommandSchema; + + if (cmd.named) { + for (let name in cmd.named) { + let arg = cmd.named[name]; + + // @ts-ignore + if (arg.required && op.named[name] == null) { + throw new TemplateError(`Required template argument "${name}" is missing.`, op.srcRange); + } + } + } + + for (let arg of op.args) this.validateTemplate(arg, ctx); + for (let name in op.named) { + if (!cmd.named || cmd.named[name] == null) { + let arg = (op.named as {[index:string]:TemplateOp})[name] as TemplateOp | undefined; + + // @ts-ignore + throw new TemplateError(`Unknown template argument "${name}".`, { + file: op.srcRange.file, + from: (typeof arg == 'object' && arg.srcRange.from - 1 - name.length) || op.srcRange.from, + to: (typeof arg == 'object' && arg.srcRange.to) || op.srcRange.to + }); + } + + // @ts-ignore + this.validateTemplate(op.named[name]!, ctx); + } + } else throw new TemplateError(`Template command "${op.op}" does not exist.`, op.srcRange); + } + + // creates an executor with the default commands. + public static createExecutor(): TemplateExecutor { + let ex = new TemplateExecutor(); + + // joins two strings + ex.registerCommand('concat', {}, (op, ctx) => { + return op.args.reduce((a: string, b): string => a + ctx.owner.executeTemplate(b, ctx), ""); + }); + + // converts a number to a file size + ex.registerCommand('formatbytes', { + named: { unit: {} } + }, (op, ctx) => { + let value = ctx.owner.executeTemplate(op.args[0], ctx); + + let exponent = (op.named.unit != null && { 'b': 0, 'kb': 1, 'mb': 2, 'gb': 3, 'tb': 4 }[ctx.owner.executeTemplate(op.named.unit, ctx)]) + || Math.max(Math.min(Math.floor(Math.log10(Number(value))/3), 4), 0); + + return `${(Number(value) / 1000 ** exponent).toFixed(2)}${['b', 'kb', 'mb', 'gb', 'tb'][exponent]}`; + }); + + // gets the size of the active file + ex.registerCommand('filesize', {}, (op, ctx) => { + return ctx.file.size.toString(); + }); + + // gets the uploader of the active file + ex.registerCommand('uploader', {}, (op, ctx) => { + return ctx.uploader.username; + }); + + // selects a random argument + ex.registerCommand('random', {}, (op, ctx) => { + if (op.args.length > 0) { + return ctx.owner.executeTemplate(op.args[Math.round(Math.random() * (op.args.length - 1))], ctx); + } else throw new TemplateError('Random without arguments'); + }); + + return ex; + } +} \ No newline at end of file diff --git a/backend/templates/parser.ts b/backend/templates/parser.ts new file mode 100644 index 0000000..50a714b --- /dev/null +++ b/backend/templates/parser.ts @@ -0,0 +1,270 @@ +import { TemplateOp, TemplateSource } from 'ass'; +import { TemplateSyntaxError } from './error'; + +enum TokenType { + T_OPEN, T_CLOSE, + PIPE, EQUALS, + TEXT, +}; + +type TemplateToken = { + type : TokenType; + data?: string; + from: number; + to: number; +}; + +// tree used by findReplacement to select the best amp-substitution +type TemplateAmpNode = { [index: string]: TemplateAmpNode | string | undefined; } +const TEMPLATE_AMP_SUBSTITUTIONS: TemplateAmpNode = { + e: { q: { $: '=' } }, + p: { i: { p: { e: { $: '|' } } } }, + t: { + c: { l: { o: { s: { e: { $: '}}' } } } } }, + o: { p: { e: { n: { $: '{{' } } } } + } +}; + +function getTemplateTokens(src: string): TemplateToken[] { + let tokens: TemplateToken[] = []; + let buf : string = ''; + let pos : number = 0; + + // digs through TEMPLATE_AMP_SUBSTITUTIONS to find + // longest possible string to replace + function findReplacement() { + let raw = ""; + let bestpos: number | null = null; + let best: string | null = null; + let node = TEMPLATE_AMP_SUBSTITUTIONS; + + while (true) { + if (pos >= src.length) break; + if (!/[a-z]/.test(src[pos])) break; + + if (node[src[pos]] != null) { // enter the thing + node = node[src[pos]] as TemplateAmpNode; + } else { + break; + } + + if (node.$ != null) { + best = node.$ as string; + bestpos = pos; + } + + raw += src[pos++]; + } + + if (best != null) { + pos = bestpos! + 1; + return best; + } + + return `&${raw}`; + } + + for (; pos < src.length; pos++) { + let lp = pos; + + if (pos + 1 < src.length && src[pos] == '{' && src[pos + 1] == '{') { + tokens.push({ + type: TokenType.T_OPEN, + from: pos, + to: pos + 1 + }); + pos++; + } else if (pos + 1 < src.length && src[pos] == '}' && src[pos + 1] == '}') { + tokens.push({ + type: TokenType.T_CLOSE, + from: pos, + to: pos + 1 + }); + pos++; + } else if (src[pos] == '|') { + tokens.push({ + type: TokenType.PIPE, + from: pos, + to: pos + }); + } else if (src[pos] == '=') { + tokens.push({ + type: TokenType.EQUALS, + from: pos, + to: pos + }); + } else if (src[pos] == '&') { + pos++; + buf += findReplacement(); + pos--; continue; + } else { + buf += src[pos]; + continue; + } + + if (buf.length) { + tokens.splice(-1, 0, { + type: TokenType.TEXT, + data: buf, + from: lp - buf.length, + to: lp - 1 + }); + buf = ''; + } + } + + if (buf.length) tokens.push({ + type: TokenType.TEXT, + data: buf, + from: src.length - buf.length, + to: src.length + }); + + return tokens; +} + +export function prepareTemplate(src: string): TemplateOp { + type ParserStackEntry = { + pos: number + }; + + let tokens = getTemplateTokens(src); + let pos = 0; + + let stack: ParserStackEntry[] = []; + + function stackPush() { + stack.push({ pos: pos }); + } + + function stackDrop() { + stack.pop(); + } + + let file: TemplateSource = { code: src }; + + // parse the text part of stuff. like uh + // you know uh like uh this part + // V---V V V-V V + // Hello {Concat|W|ORL|D} + function parseConcat(root: boolean = false): TemplateOp { + let joined: TemplateOp[] = []; + + let start = pos; + + stackPush(); + + out: while (pos < tokens.length) { + switch (tokens[pos].type) { + case TokenType.TEXT: + joined.push(tokens[pos++].data!); + continue out; + case TokenType.EQUALS: + if (root == true) throw new TemplateSyntaxError('Unexpected equals', { file: file, from: tokens[pos].from, to: tokens[pos].to }); + case TokenType.PIPE: + if (root == true) throw new TemplateSyntaxError('Unexpected pipe', { file: file, from: tokens[pos].from, to: tokens[pos].to }); + case TokenType.T_CLOSE: + if (root == true) throw new TemplateSyntaxError('Unexpected closing tag', { file: file, from: tokens[pos].from, to: tokens[pos].to }); + break out; + case TokenType.T_OPEN: + joined.push(parseTemplate()); + } + } + + stackDrop(); + + return joined.length == 1 ? joined[0] : { + op: "concat", + named: {}, + args: joined, + srcRange: { + file: file, + from: tokens[start].from, + to: tokens[pos - 1].to + } + }; + } + + // parse templates + function parseTemplate(): TemplateOp { + let name: string; + let args: TemplateOp[] = []; + let nargs: {[index: string]: TemplateOp} = {}; + let start = pos; + + stackPush(); + + if (pos < tokens.length && tokens[pos].type == TokenType.T_OPEN) { + pos++; + } else throw new Error('Catastrophic failure'); + + if (pos < tokens.length && tokens[pos].type == TokenType.TEXT) { + name = tokens[pos++].data!; + } else if (pos < tokens.length) { + if (tokens[pos].type == TokenType.T_CLOSE) { + throw new TemplateSyntaxError('Template name missing', { file: file, from: tokens[pos - 1].from, to: tokens[pos].to }); + } else throw new TemplateSyntaxError('Expected template name', { file: file, from: tokens[pos].from, to: tokens[pos].to }); + } else throw new TemplateSyntaxError('Unexpected end of file'); + + if (pos < tokens.length && tokens[pos].type == TokenType.PIPE) { + pos++; + + out: while (pos < tokens.length) { + let argStart = pos; + + let arg = parseConcat(); + + // this is some really nasty control flow im so sorry + if (pos < tokens.length) { + switch (tokens[pos].type) { + case TokenType.EQUALS: // named arguments + if (typeof arg != 'string') { + throw new TemplateSyntaxError('Argument name must be a plain string', { file: file, from: tokens[argStart].from, to: tokens[pos - 1].to }); + } + + pos++; + if (pos < tokens.length) { + let arg2 = parseConcat(); + nargs[arg] = arg2; + if (pos < tokens.length) { + switch (tokens[pos].type) { + case TokenType.T_CLOSE: break out; + case TokenType.PIPE: pos++; + } + } else throw new TemplateSyntaxError('syntax error'); + } else throw new TemplateSyntaxError('syntax error'); + break; + case TokenType.T_CLOSE: + args.push(arg); + break out; + case TokenType.PIPE: + args.push(arg); + pos++; + } + } + } + } else if (pos < tokens.length && tokens[pos].type != TokenType.T_CLOSE) { + throw new TemplateSyntaxError('Expected arguments or closing tag', { file: file, from: tokens[pos].from, to: tokens[pos].to }); + } + + if (pos < tokens.length && tokens[pos].type == TokenType.T_CLOSE) { + pos++; + } else throw new TemplateSyntaxError('Template closing tag missing'); + + stackDrop(); + + return { + op: name.toLocaleLowerCase(), + named: nargs, + args: args, + srcRange: { + file: file, + from: tokens[start].from, + to: tokens[pos - 1].to + } + }; + } + + let result = parseConcat(true); + return result; +} \ No newline at end of file diff --git a/common/types.d.ts b/common/types.d.ts index 322ae5a..1e7fbe2 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -275,60 +275,66 @@ declare module 'ass' { meta: { [key: string]: any }; } - // Generic embed template operation - type EmbedTemplateOperation = - EmbedTemplateRandomOperation | - EmbedTemplateFileSizeOperation | - EmbedTemplateFormatBytesOperation | - EmbedTemplateUploaderOperation | - EmbedTemplateConcatOperation | - string | number; - /** - * Selects one operation and executes it + * Template operation */ - type EmbedTemplateRandomOperation = { - op: 'random'; - values: EmbedTemplateOperation[]; - }; + type TemplateOp = TemplateCommandOp | string; /** - * Returns the file size in bytes - * @returns number + * Please don't waste your time trying to make this look + * nice, it's not possible. */ - type EmbedTemplateFileSizeOperation = { op: 'fileSize' }; + type TemplateCommandOp = { + op: N; + args: TemplateOp[]; + named: { + +readonly [name in keyof T['named']]: ( + TemplateOp | (T['named'] extends object + ? T['named'][name] extends { required?: boolean } + ? T['named'][name]['required'] extends true + ? TemplateOp + : undefined + : undefined + : undefined) + ) + }; + srcRange: TemplateSourceRange; + }; /** - * Formats the file size in in {value} + * Basically a declaration */ - type EmbedTemplateFormatBytesOperation = { - op: 'formatBytes'; - unit?: EmbedTemplateOperation; // ready for ios! - value: EmbedTemplateOperation; + type TemplateCommandSchema = { + named?: { + [index: string]: { + required?: boolean + } + }; }; /** - * Returns the user who uploaded this file + * Template source code */ - type EmbedTemplateUploaderOperation = { - op: 'uploader' + type TemplateSource = { + code: string; }; /** - * Joins strings + * Range in template source code */ - type EmbedTemplateConcatOperation = { - op: 'concat', - values: EmbedTemplateOperation[] - } + type TemplateSourceRange = { + file: TemplateSource; + from: number; + to: number; + }; /** * This is so beyond cursed */ interface EmbedTemplate { - title: EmbedTemplateOperation; - description: EmbedTemplateOperation; - sitename: EmbedTemplateOperation; + title: TemplateOp; + description: TemplateOp; + sitename: TemplateOp; } /**