Merge branch 'dev/0.15.0-embeds' into dev/0.15.0

pull/249/head
xwashere 6 months ago
commit c6a5007939
No known key found for this signature in database
GPG Key ID: 042F8BFA1B0EF93B

@ -2,7 +2,11 @@ import { UserConfiguration, UserConfigTypeChecker, PostgresConfiguration, MongoD
import fs from 'fs-extra';
import { path } from '@tycrek/joint';
import { log } from './log.js';
import { prepareTemplate } from './templates/parser.js';
import { TemplateError } from './templates/error.js';
import { DEFAULT_EMBED, validateEmbed } from './embed.js';
const FILEPATH = path.join('.ass-data/userconfig.json');
@ -130,6 +134,30 @@ 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, {
allowIncludeFile: true
});
} 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;
}

@ -0,0 +1,32 @@
import { AssFile, AssUser, EmbedTemplate, PreparedEmbed } from 'ass'
import { TemplateExecutor } from './templates/executor.js';
let executor = TemplateExecutor.createExecutor();
export const DEFAULT_EMBED: EmbedTemplate = {
sitename: 'ass',
title: '',
description: ''
};
// ensures a template is valid
export const validateEmbed = (template: EmbedTemplate) => {
// lets hope this works
let context = executor.createContext(null!, null!);
executor.validateTemplate(template.title, context);
executor.validateTemplate(template.description, context);
executor.validateTemplate(template.sitename, context);
}
// cooks up the embed
export const prepareEmbed = (template: EmbedTemplate, user: AssUser, file: AssFile): PreparedEmbed => {
let context = executor.createContext(user, file);
return {
title: executor.executeTemplate(template.title, context),
description: executor.executeTemplate(template.description, context),
sitename: executor.executeTemplate(template.sitename, context)
};
};

@ -1,4 +1,4 @@
import { BusBoyFile, AssFile } from 'ass';
import { BusBoyFile, AssFile, AssUser } from 'ass';
import fs from 'fs-extra';
import bb from 'express-busboy';
@ -13,6 +13,8 @@ import { random } from '../generators.js';
import { UserConfig } from '../UserConfig.js';
import { getFileS3, uploadFileS3 } from '../s3.js';
import { rateLimiterMiddleware } from '../ratelimit.js';
import { DBManager } from '../sql/database.js';
import { DEFAULT_EMBED, prepareEmbed } from '../embed.js';
const router = Router({ caseSensitive: true });
@ -96,7 +98,47 @@ router.post('/', rateLimiterMiddleware('upload', UserConfig.config?.rateLimit?.u
}
});
router.get('/:fakeId', (req, res) => res.redirect(`/direct/${req.params.fakeId}`));
router.get('/:fakeId', async (req, res) => {
if (!UserConfig.ready) res.redirect('/setup');
// Get the ID
const fakeId = req.params.fakeId;
// Get the file metadata
let _data;
try { _data = await DBManager.get('assfiles', fakeId); }
catch (err) {
log.error('Failed to get', fakeId);
console.error(err);
return res.status(500).send();
}
if (!_data) return res.status(404).send();
else {
let meta = _data as AssFile;
let user = await DBManager.get('assusers', meta.uploader) as AssUser | undefined;
res.render("viewer", {
url: `/direct/${fakeId}`,
uploader: user?.username ?? 'unknown',
size: meta.size,
time: meta.timestamp,
embed: prepareEmbed({
title: UserConfig.config.embed?.title ?? DEFAULT_EMBED.title,
description: UserConfig.config.embed?.description ?? DEFAULT_EMBED.description,
sitename: UserConfig.config.embed?.sitename ?? DEFAULT_EMBED.sitename
}, user ?? {
admin: false,
files: [],
id: "",
meta: {},
password: "",
tokens: [],
username: "unknown"
}, meta)
});
}
});
router.get('/direct/:fakeId', async (req, res) => {
if (!UserConfig.ready) res.redirect('/setup');

@ -0,0 +1,10 @@
import { TemplateCommandOp, TemplateCommandSchema } from 'ass';
import { TemplateContext } from './executor.js';
export type TemplateCommand<N extends string, S extends TemplateCommandSchema> = {
readonly name: N;
readonly schema: S;
exec: (op: TemplateCommandOp<N, S>, ctx: TemplateContext) => string;
};

@ -0,0 +1,81 @@
import { TemplateSourceRange } from 'ass';
export class TemplateError extends Error {
range?: TemplateSourceRange;
constructor(msg: string, range?: TemplateSourceRange) {
super(msg);
this.range = range;
}
/**
* Formats the error.
*/
public format(): string {
let format = '';
if (this.range) {
let fcol = 0;
let fline = 1;
let pstart = 0;
for (let i = 0; i < this.range.from; i++) {
fcol++;
if (this.range.file.code[i] == '\n') {
fline++;
fcol = 0;
pstart = i + 1;
}
}
let tcol = fcol;
let tline = fline;
let pend = pstart;
for (let i = this.range.from; i < this.range.to; i++) {
tcol++;
if (this.range.file.code[i] == '\n') {
tline++;
tcol = 0;
pend = i + 1;
}
}
if ((pend = this.range.file.code.indexOf('\n', pend)) == -1) {
pend = this.range.file.code.length - 1;
}
if (fline == tline) {
format += `${fline.toString().padStart(5, ' ')} | ${this.range.file.code.substring(pstart, pend + 1)}\n`;
format += `${fline.toString().padStart(5, ' ')} | ${' '.repeat(fcol)}^${'~'.repeat(Math.max(tcol - fcol, 0))}\n`;
} else {
let lines = this.range.file.code.substring(pstart, pend + 1).split('\n');
format += ` | /${'~'.repeat(lines[0].length)}v\n`;
for (let i = fline; i < fline + 5 && i <= tline; i++) {
format += `${i.toString().padStart(5, ' ')} | | ${lines[i - fline]}\n`;
}
if (fline + 5 < tline) {
format += ` | | ...\n`;
for (let i = tline - 4; i <= tline; i++) {
format += `${i.toString().padStart(5, ' ')} | | ${lines[i - fline]}\n`;
}
}
format += ` | \\${'~'.repeat(tcol + 1)}^\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 {};

@ -0,0 +1,122 @@
import { AssFile, AssUser, TemplateCommandOp, TemplateCommandSchema, TemplateOp } from 'ass';
import { TemplateCommand } from './command.js';
import { TemplateError } from './error.js';
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<any, any> } = {};
// register a template command globally
public registerCommand<N extends string, S extends TemplateCommandSchema>(name: N, attrs: S, cmd: (op: TemplateCommandOp<N, S>, 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;
}
}

@ -0,0 +1,305 @@
import { TemplateOp, TemplateSource } from 'ass';
import fs from 'fs';
import { TemplateSyntaxError } from './error.js';
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 if (src[pos] == '\n') {
pos++;
for (; pos < src.length && (src[pos] == ' ' || src[pos] == '\t'); pos++);
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 type PrepareTemplateOptions = {
allowIncludeFile?: boolean;
};
export function prepareTemplate(src: string, config?: PrepareTemplateOptions): TemplateOp {
let options = {
includeFiles: config?.allowIncludeFile ?? false
};
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 ?? 0,
to: tokens[pos - 1]?.to ?? src.length - 1
}
};
}
// 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();
// include is executed early
if (name.toLowerCase() == 'include') {
if (nargs['file'] != null) {
// security check!
if (!options.includeFiles) {
throw new TemplateSyntaxError('You are not allowed to include files', { file: file, from: tokens[start].from, to: tokens[pos - 1].to });
}
if (typeof nargs['file'] == 'string') {
if (fs.existsSync(nargs['file'])) {
let template = fs.readFileSync(nargs['file'], { encoding: 'utf-8' });
let tl = prepareTemplate(template, config);
return tl;
} else throw new TemplateSyntaxError('File does not exist', { file: file, from: tokens[start].from, to: tokens[pos - 1].to});
} else throw new TemplateSyntaxError('Include directive can not contain templates', { file: file, from: tokens[start].from, to: tokens[pos - 1].to});
} else throw new TemplateSyntaxError(`Bad include directive`, { file: file, from: tokens[start].from, to: tokens[pos - 1].to});
}
return {
op: name.toLocaleLowerCase(),
named: nargs,
args: args,
srcRange: {
file: file,
from: tokens[start]?.from ?? 0,
to: tokens[pos - 1]?.to ?? src.length - 1
}
};
}
let result = parseConcat(true);
return result;
}

90
common/types.d.ts vendored

@ -27,6 +27,25 @@ declare module 'ass' {
database?: DatabaseConfiguration;
rateLimit?: RateLimitConfiguration;
// to whoever has to make the config screen
// for this, im very verys sorry
embed?: EmbedTemplate;
}
/**
* Embed config
*/
interface EmbedConfiguration {
/**
* Title in embed
*/
title?: string,
/**
* Description(s) in embed
*/
description?: string[] | string,
}
interface S3Configuration {
@ -255,6 +274,77 @@ declare module 'ass' {
cliKey: string;
meta: { [key: string]: any };
}
/**
* Template operation
*/
type TemplateOp = TemplateCommandOp<any, TemplateCommandSchema> | string;
/**
* Please don't waste your time trying to make this look
* nice, it's not possible.
*/
type TemplateCommandOp<N extends string, T extends TemplateCommandSchema> = {
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;
};
/**
* Basically a declaration
*/
type TemplateCommandSchema = {
named?: {
[index: string]: {
required?: boolean
}
};
};
/**
* Template source code
*/
type TemplateSource = {
code: string;
};
/**
* Range in template source code
*/
type TemplateSourceRange = {
file: TemplateSource;
from: number;
to: number;
};
/**
* This is so beyond cursed
*/
interface EmbedTemplate {
title: TemplateOp;
description: TemplateOp;
sitename: TemplateOp;
}
/**
*
*/
interface PreparedEmbed {
title: string;
description: string;
sitename: string;
}
}
//#region Dummy modules

@ -24,6 +24,15 @@
.setup-panel>sl-input {
@apply mb-4;
}
.res-image {
@apply max-h-[75vh];
}
/* THANKS TAILWIND */
sl-divider {
border: solid var(--width) var(--color);
}
}
@layer utilities {

@ -0,0 +1,60 @@
doctype html
html.dark.sl-theme-dark(lang='en', prefix='og: https://ogp.me/ns')
head
//- this stuff
meta(charset='UTF-8')
meta(name='viewport', content='width=device-witdh, initial-scale=1.0')
meta(name='theme-color' content='black')
link(rel='stylesheet' href='/.css')
//- title
title ass 🍑
//- embed data
meta(property='og:title', content=embed.title)
meta(property='og:description', content=embed.description)
meta(property='og:site_name', content=embed.sitename)
meta(property='og:type', content='image')
meta(property='og:image', content=url)
meta(property='og:url', content='.')
meta(property='twitter:card', content='summary_large_image')
//- mixins
include ../node_modules/shoelace-fontawesome-pug/sl-fa-mixin.pug
include ../node_modules/shoelace-pug-loader/loader.pug
//- shoelace
+slTheme('dark')
+slAuto
body.w-screen.h-screen.flex-col
div.w-full.h-full.flex.justify-center.items-center.text-center
main.flex.flex-col
- let borderStyle = { 'border-color': 'var(--sl-color-neutral-200)' };
header.border-t.border-x.flex.h-8.bg-stone-900(style=borderStyle)
- let dividerStyle = { '--spacing': '0px', 'margin-left': '8px' };
//- uploader
span
sl-icon.p-2.align-middle(name='person-fill', label='uploader')
| #{uploader}
sl-divider(vertical, style=dividerStyle)
//- file size
span
sl-icon.p-2.align-middle(name='database-fill', label='size')
sl-format-bytes(value=size)
sl-divider(vertical, style=dividerStyle)
//- upload date
span
//- calendar is a funny word
sl-icon.p-2.align-middle(name='calendar-fill', label='upload date')
sl-format-date(value=time)
sl-divider(vertical, style=dividerStyle)
//- spacer
div.flex-grow
//- download button
sl-divider(vertical, style=dividerStyle)
span.float-right
sl-icon-button(name='download', href=url, download, label='download')
img.res-image.border-b.border-x(src=url, style=borderStyle)
Loading…
Cancel
Save