Add many eslint rules and fix all linting issues

This commit is contained in:
Alice Gaudon 2020-09-25 23:42:15 +02:00
parent 8210642684
commit 79d704083a
75 changed files with 1400 additions and 3964 deletions

View File

@ -4,15 +4,106 @@
"plugins": [ "plugins": [
"@typescript-eslint" "@typescript-eslint"
], ],
"parserOptions": {
"project": [
"./tsconfig.json",
"./tsconfig.test.json"
]
},
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended" "plugin:@typescript-eslint/recommended"
], ],
"rules": { "rules": {
"@typescript-eslint/no-inferrable-types": 0 "indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-trailing-spaces": "error",
"max-len": [
"error",
{
"code": 120,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"semi": "off",
"@typescript-eslint/semi": [
"error"
],
"no-extra-semi": "error",
"eol-last": "error",
"comma-dangle": "off",
"@typescript-eslint/comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "always-multiline",
"enums": "always-multiline",
"generics": "always-multiline",
"tuples": "always-multiline"
}
],
"no-extra-parens": "off",
"@typescript-eslint/no-extra-parens": [
"error"
],
"no-nested-ternary": "error",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/explicit-module-boundary-types": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-non-null-assertion": "error",
"no-useless-return": "error",
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": [
"error"
],
"no-return-await": "off",
"@typescript-eslint/return-await": [
"error",
"always"
],
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
"accessibility": "explicit"
}
]
}, },
"ignorePatterns": [ "ignorePatterns": [
"jest.config.js", "jest.config.js",
"dist/**/*" "dist/**/*",
"config/**/*"
],
"overrides": [
{
"files": [
"test/**/*"
],
"rules": {
"max-len": [
"error",
{
"code": 120,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true,
"ignoreStrings": true
}
]
}
}
] ]
} }

View File

@ -1,4 +1,9 @@
module.exports = { module.exports = {
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json',
}
},
transform: { transform: {
"^.+\\.ts$": "ts-jest" "^.+\\.ts$": "ts-jest"
}, },
@ -10,5 +15,5 @@ module.exports = {
testMatch: [ testMatch: [
'**/test/**/*.test.ts' '**/test/**/*.test.ts'
], ],
testEnvironment: 'node' testEnvironment: 'node',
}; };

View File

@ -6,7 +6,7 @@ import WebSocketListener from "./WebSocketListener";
import ApplicationComponent from "./ApplicationComponent"; import ApplicationComponent from "./ApplicationComponent";
import Controller from "./Controller"; import Controller from "./Controller";
import MysqlConnectionManager from "./db/MysqlConnectionManager"; import MysqlConnectionManager from "./db/MysqlConnectionManager";
import Migration from "./db/Migration"; import Migration, {MigrationType} from "./db/Migration";
import {Type} from "./Utils"; import {Type} from "./Utils";
import LogRequestsComponent from "./components/LogRequestsComponent"; import LogRequestsComponent from "./components/LogRequestsComponent";
import {ValidationBag} from "./db/Validator"; import {ValidationBag} from "./db/Validator";
@ -19,7 +19,7 @@ import RedisComponent from "./components/RedisComponent";
import Extendable from "./Extendable"; import Extendable from "./Extendable";
import TemplateError = lib.TemplateError; import TemplateError = lib.TemplateError;
export default abstract class Application implements Extendable<ApplicationComponent> { export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
private readonly version: string; private readonly version: string;
private readonly ignoreCommandLine: boolean; private readonly ignoreCommandLine: boolean;
private readonly controllers: Controller[] = []; private readonly controllers: Controller[] = [];
@ -34,11 +34,11 @@ export default abstract class Application implements Extendable<ApplicationCompo
this.ignoreCommandLine = ignoreCommandLine; this.ignoreCommandLine = ignoreCommandLine;
} }
protected abstract getMigrations(): Type<Migration>[]; protected abstract getMigrations(): MigrationType<Migration>[];
protected abstract async init(): Promise<void>; protected abstract async init(): Promise<void>;
protected use(thing: Controller | WebSocketListener<this> | ApplicationComponent) { protected use(thing: Controller | WebSocketListener<this> | ApplicationComponent): void {
if (thing instanceof Controller) { if (thing instanceof Controller) {
thing.setApp(this); thing.setApp(this);
this.controllers.push(thing); this.controllers.push(thing);
@ -88,7 +88,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
app.use(handleRouter); app.use(handleRouter);
// Error handlers // Error handlers
app.use((err: any, req: Request, res: Response, next: NextFunction) => { app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) return next(err); if (res.headersSent) return next(err);
if (err instanceof ValidationBag) { if (err instanceof ValidationBag) {
@ -104,17 +104,18 @@ export default abstract class Application implements Extendable<ApplicationCompo
}, },
text: () => { text: () => {
res.status(401); res.status(401);
res.send('Error: ' + err.getMessages()) res.send('Error: ' + err.getMessages());
}, },
html: () => { html: () => {
req.flash('validation', err.getMessages()); req.flash('validation', err.getMessages().toString());
res.redirectBack(); res.redirectBack();
}, },
}); });
return; return;
} }
let errorID: string = LogRequestsComponent.logRequest(req, res, err, '500 Internal Error', err instanceof BadRequestError || err instanceof ServiceUnavailableHttpError); const errorId: string = LogRequestsComponent.logRequest(req, res, err, '500 Internal Error',
err instanceof BadRequestError || err instanceof ServiceUnavailableHttpError);
let httpError: HttpError; let httpError: HttpError;
@ -123,7 +124,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
} else if (err instanceof TemplateError && err.cause instanceof HttpError) { } else if (err instanceof TemplateError && err.cause instanceof HttpError) {
httpError = err.cause; httpError = err.cause;
} else { } else {
httpError = new ServerError('Internal server error.', err); httpError = new ServerError('Internal server error.', err instanceof Error ? err : undefined);
} }
res.status(httpError.errorCode); res.status(httpError.errorCode);
@ -133,7 +134,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
error_code: httpError.errorCode, error_code: httpError.errorCode,
error_message: httpError.message, error_message: httpError.message,
error_instructions: httpError.instructions, error_instructions: httpError.instructions,
error_id: errorID, error_id: errorId,
}); });
}, },
json: () => { json: () => {
@ -142,12 +143,12 @@ export default abstract class Application implements Extendable<ApplicationCompo
code: httpError.errorCode, code: httpError.errorCode,
message: httpError.message, message: httpError.message,
instructions: httpError.instructions, instructions: httpError.instructions,
error_id: errorID, error_id: errorId,
}); });
}, },
default: () => { default: () => {
res.type('txt').send(`${httpError.errorCode} - ${httpError.message}\n\n${httpError.instructions}\n\nError ID: ${errorID}`); res.type('txt').send(`${httpError.errorCode} - ${httpError.message}\n\n${httpError.instructions}\n\nError ID: ${errorId}`);
} },
}); });
}); });
@ -262,18 +263,20 @@ export default abstract class Application implements Extendable<ApplicationCompo
return this.webSocketListeners; return this.webSocketListeners;
} }
public getCache(): CacheProvider | undefined { public getCache(): CacheProvider | null {
return this.cacheProvider; return this.cacheProvider || null;
} }
public as<C extends ApplicationComponent>(type: Type<C>): C { public as<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): C {
const component = this.components.find(component => component.constructor === type); const module = this.components.find(component => component.constructor === type) ||
if (!component) throw new Error(`This app doesn't have a ${type.name} component.`); Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
return component as C; if (!module) throw new Error(`This app doesn't have a ${type.name} component.`);
return module as C;
} }
public asOptional<C extends ApplicationComponent>(type: Type<C>): C | null { public asOptional<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): C | null {
const component = this.components.find(component => component.constructor === type); const module = this.components.find(component => component.constructor === type) ||
return component ? component as C : null; Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
return module ? module as C : null;
} }
} }

View File

@ -1,10 +1,10 @@
import {Express, Router} from "express"; import {Express, Router} from "express";
import Logger from "./Logger"; import Logger from "./Logger";
import {sleep, Type} from "./Utils"; import {sleep} from "./Utils";
import Application from "./Application"; import Application from "./Application";
import config from "config"; import config from "config";
import SecurityError from "./SecurityError"; import SecurityError from "./SecurityError";
import Middleware from "./Middleware"; import Middleware, {MiddlewareType} from "./Middleware";
export default abstract class ApplicationComponent { export default abstract class ApplicationComponent {
private currentRouter?: Router; private currentRouter?: Router;
@ -28,16 +28,16 @@ export default abstract class ApplicationComponent {
err = null; err = null;
} catch (e) { } catch (e) {
err = e; err = e;
Logger.error(err, `${name} failed to prepare; retrying in 5s...`) Logger.error(err, `${name} failed to prepare; retrying in 5s...`);
await sleep(5000); await sleep(5000);
} }
} while (err); } while (err);
Logger.info(`${name} ready!`); Logger.info(`${name} ready!`);
} }
protected async close(thingName: string, thing: any, fn: Function): Promise<void> { protected async close(thingName: string, fn: (callback: (err?: Error | null) => void) => void): Promise<void> {
try { try {
await new Promise((resolve, reject) => fn.call(thing, (err: any) => { await new Promise((resolve, reject) => fn((err?: Error | null) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
})); }));
@ -48,13 +48,13 @@ export default abstract class ApplicationComponent {
} }
} }
protected checkSecurityConfigField(field: string) { protected checkSecurityConfigField(field: string): void {
if (!config.has(field) || config.get<string>(field) === 'default') { if (!config.has(field) || config.get<string>(field) === 'default') {
throw new SecurityError(`${field} field not configured.`); throw new SecurityError(`${field} field not configured.`);
} }
} }
protected use<M extends Middleware>(middleware: Type<M>): void { protected use<M extends Middleware>(middleware: MiddlewareType<M>): void {
if (!this.currentRouter) throw new Error('Cannot call this method outside init() and handle().'); if (!this.currentRouter) throw new Error('Cannot call this method outside init() and handle().');
const instance = new middleware(this.getApp()); const instance = new middleware(this.getApp());
@ -67,10 +67,6 @@ export default abstract class ApplicationComponent {
}); });
} }
protected getCurrentRouter(): Router | null {
return this.currentRouter || null;
}
public setCurrentRouter(router: Router | null): void { public setCurrentRouter(router: Router | null): void {
this.currentRouter = router || undefined; this.currentRouter = router || undefined;
} }
@ -80,7 +76,7 @@ export default abstract class ApplicationComponent {
return this.app; return this.app;
} }
public setApp(app: Application) { public setApp(app: Application): void {
this.app = app; this.app = app;
} }
} }

View File

@ -6,14 +6,18 @@ import Validator, {ValidationBag} from "./db/Validator";
import FileUploadMiddleware from "./FileUploadMiddleware"; import FileUploadMiddleware from "./FileUploadMiddleware";
import * as querystring from "querystring"; import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring"; import {ParsedUrlQueryInput} from "querystring";
import Middleware from "./Middleware"; import Middleware, {MiddlewareType} from "./Middleware";
import {Type} from "./Utils";
import Application from "./Application"; import Application from "./Application";
export default abstract class Controller { export default abstract class Controller {
private static readonly routes: { [p: string]: string } = {}; private static readonly routes: { [p: string]: string | undefined } = {};
public static route(route: string, params: RouteParams = [], query: ParsedUrlQueryInput = {}, absolute: boolean = false): string { public static route(
route: string,
params: RouteParams = [],
query: ParsedUrlQueryInput = {},
absolute: boolean = false,
): string {
let path = this.routes[route]; let path = this.routes[route];
if (path === undefined) throw new Error(`Unknown route for name ${route}.`); if (path === undefined) throw new Error(`Unknown route for name ${route}.`);
@ -29,12 +33,10 @@ export default abstract class Controller {
} }
path = path.replace(/\/+/g, '/'); path = path.replace(/\/+/g, '/');
} else { } else {
for (const key in params) { for (const key of Object.keys(params)) {
if (params.hasOwnProperty(key)) {
path = path.replace(new RegExp(`:${key}\\??`), params[key]); path = path.replace(new RegExp(`:${key}\\??`), params[key]);
} }
} }
}
const queryStr = querystring.stringify(query); const queryStr = querystring.stringify(query);
return `${absolute ? config.get<string>('base_url') : ''}${path}` + (queryStr.length > 0 ? '?' + queryStr : ''); return `${absolute ? config.get<string>('base_url') : ''}${path}` + (queryStr.length > 0 ? '?' + queryStr : '');
@ -64,10 +66,7 @@ export default abstract class Controller {
public abstract routes(): void; public abstract routes(): void;
public setupRoutes(): { public setupRoutes(): { mainRouter: Router, fileUploadFormRouter: Router } {
mainRouter: Router,
fileUploadFormRouter: Router
} {
this.routes(); this.routes();
return { return {
mainRouter: this.router, mainRouter: this.router,
@ -75,23 +74,43 @@ export default abstract class Controller {
}; };
} }
protected use(handler: RequestHandler) { protected use(handler: RequestHandler): void {
this.router.use(handler); this.router.use(handler);
} }
protected get(path: PathParams, handler: RequestHandler, routeName?: string, ...middlewares: (Type<Middleware>)[]) { protected get(
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.handle('get', path, handler, routeName, ...middlewares); this.handle('get', path, handler, routeName, ...middlewares);
} }
protected post(path: PathParams, handler: RequestHandler, routeName?: string, ...middlewares: (Type<Middleware>)[]) { protected post(
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.handle('post', path, handler, routeName, ...middlewares); this.handle('post', path, handler, routeName, ...middlewares);
} }
protected put(path: PathParams, handler: RequestHandler, routeName?: string, ...middlewares: (Type<Middleware>)[]) { protected put(
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.handle('put', path, handler, routeName, ...middlewares); this.handle('put', path, handler, routeName, ...middlewares);
} }
protected delete(path: PathParams, handler: RequestHandler, routeName?: string, ...middlewares: (Type<Middleware>)[]) { protected delete(
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.handle('delete', path, handler, routeName, ...middlewares); this.handle('delete', path, handler, routeName, ...middlewares);
} }
@ -100,7 +119,7 @@ export default abstract class Controller {
path: PathParams, path: PathParams,
handler: RequestHandler, handler: RequestHandler,
routeName?: string, routeName?: string,
...middlewares: (Type<Middleware>)[] ...middlewares: (MiddlewareType<Middleware>)[]
): void { ): void {
this.registerRoutes(path, handler, routeName); this.registerRoutes(path, handler, routeName);
for (const middleware of middlewares) { for (const middleware of middlewares) {
@ -152,11 +171,13 @@ export default abstract class Controller {
} }
} }
protected async validate(validationMap: { [p: string]: Validator<any> }, body: any): Promise<void> { protected async validate(
validationMap: { [p: string]: Validator<unknown> },
body: { [p: string]: unknown },
): Promise<void> {
const bag = new ValidationBag(); const bag = new ValidationBag();
for (const p in validationMap) { for (const p of Object.keys(validationMap)) {
if (validationMap.hasOwnProperty(p)) {
try { try {
await validationMap[p].execute(p, body[p], false); await validationMap[p].execute(p, body[p], false);
} catch (e) { } catch (e) {
@ -165,7 +186,6 @@ export default abstract class Controller {
} else throw e; } else throw e;
} }
} }
}
if (bag.hasMessages()) throw bag; if (bag.hasMessages()) throw bag;
} }
@ -175,7 +195,7 @@ export default abstract class Controller {
return this.app; return this.app;
} }
public setApp(app: Application) { public setApp(app: Application): void {
this.app = app; this.app = app;
} }
} }

View File

@ -11,7 +11,7 @@ export default abstract class FileUploadMiddleware extends Middleware {
public async handle(req: Request, res: Response, next: NextFunction): Promise<void> { public async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const form = this.makeForm(); const form = this.makeForm();
try { try {
await new Promise<any>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
form.parse(req, (err, fields, files) => { form.parse(req, (err, fields, files) => {
if (err) { if (err) {
reject(err); reject(err);

View File

@ -8,7 +8,7 @@ export abstract class HttpError extends WrappingError {
this.instructions = instructions; this.instructions = instructions;
} }
get name(): string { public get name(): string {
return this.constructor.name; return this.constructor.name;
} }
@ -18,87 +18,83 @@ export abstract class HttpError extends WrappingError {
export class BadRequestError extends HttpError { export class BadRequestError extends HttpError {
public readonly url: string; public readonly url: string;
constructor(message: string, instructions: string, url: string, cause?: Error) { public constructor(message: string, instructions: string, url: string, cause?: Error) {
super(message, instructions, cause); super(message, instructions, cause);
this.url = url; this.url = url;
} }
get errorCode(): number { public get errorCode(): number {
return 400; return 400;
} }
} }
export class UnauthorizedHttpError extends BadRequestError { export class UnauthorizedHttpError extends BadRequestError {
constructor(message: string, url: string, cause?: Error) { public constructor(message: string, url: string, cause?: Error) {
super(message, '', url, cause); super(message, '', url, cause);
} }
get errorCode(): number { public get errorCode(): number {
return 401; return 401;
} }
} }
export class ForbiddenHttpError extends BadRequestError { export class ForbiddenHttpError extends BadRequestError {
constructor(thing: string, url: string, cause?: Error) { public constructor(thing: string, url: string, cause?: Error) {
super( super(
`You don't have access to this ${thing}.`, `You don't have access to this ${thing}.`,
`${url} doesn't belong to *you*.`, `${url} doesn't belong to *you*.`,
url, url,
cause cause,
); );
} }
get errorCode(): number { public get errorCode(): number {
return 403; return 403;
} }
} }
export class NotFoundHttpError extends BadRequestError { export class NotFoundHttpError extends BadRequestError {
constructor(thing: string, url: string, cause?: Error) { public constructor(thing: string, url: string, cause?: Error) {
super( super(
`${thing.charAt(0).toUpperCase()}${thing.substr(1)} not found.`, `${thing.charAt(0).toUpperCase()}${thing.substr(1)} not found.`,
`${url} doesn't exist or was deleted.`, `${url} doesn't exist or was deleted.`,
url, url,
cause cause,
); );
} }
get errorCode(): number { public get errorCode(): number {
return 404; return 404;
} }
} }
export class TooManyRequestsHttpError extends BadRequestError { export class TooManyRequestsHttpError extends BadRequestError {
constructor(retryIn: number, cause?: Error) { public constructor(retryIn: number, cause?: Error) {
super( super(
`You're making too many requests!`, `You're making too many requests!`,
`We need some rest. Please retry in ${Math.floor(retryIn / 1000)} seconds.`, `We need some rest. Please retry in ${Math.floor(retryIn / 1000)} seconds.`,
'', '',
cause cause,
); );
} }
get errorCode(): number { public get errorCode(): number {
return 429; return 429;
} }
} }
export class ServerError extends HttpError { export class ServerError extends HttpError {
constructor(message: string, cause?: Error) { public constructor(message: string, cause?: Error) {
super(message, `Maybe you should contact us; see instructions below.`, cause); super(message, `Maybe you should contact us; see instructions below.`, cause);
} }
get errorCode(): number { public get errorCode(): number {
return 500; return 500;
} }
} }
export class ServiceUnavailableHttpError extends ServerError { export class ServiceUnavailableHttpError extends ServerError {
constructor(message: string, cause?: Error) { public get errorCode(): number {
super(message, cause);
}
get errorCode(): number {
return 503; return 503;
} }
} }

View File

@ -1,17 +1,31 @@
import config from "config"; import config from "config";
import {v4 as uuid} from "uuid"; import {v4 as uuid} from "uuid";
import Log from "./models/Log"; import Log from "./models/Log";
import {bufferToUUID} from "./Utils"; import {bufferToUuid} from "./Utils";
import ModelFactory from "./db/ModelFactory";
export enum LogLevel {
ERROR,
WARN,
INFO,
DEBUG,
DEV,
}
export type LogLevelKeys = keyof typeof LogLevel;
/**
* TODO: make logger not static
*/
export default class Logger { export default class Logger {
private static logLevel: LogLevelKeys = <LogLevelKeys>config.get<string>('log.level'); private static logLevel: LogLevel = LogLevel[<LogLevelKeys>config.get<string>('log.level')];
private static dbLogLevel: LogLevelKeys = <LogLevelKeys>config.get<string>('log.db_level'); private static dbLogLevel: LogLevel = LogLevel[<LogLevelKeys>config.get<string>('log.db_level')];
private static verboseMode: boolean = config.get<boolean>('log.verbose'); private static verboseMode: boolean = config.get<boolean>('log.verbose');
public static verbose() { public static verbose(): void {
this.verboseMode = true; this.verboseMode = true;
this.logLevel = <LogLevelKeys>LogLevel[LogLevel[this.logLevel] + 1] || this.logLevel; if (LogLevel[this.logLevel + 1]) this.logLevel++;
this.dbLogLevel = <LogLevelKeys>LogLevel[LogLevel[this.dbLogLevel] + 1] || this.dbLogLevel; if (LogLevel[this.dbLogLevel + 1]) this.dbLogLevel++;
Logger.info('Verbose mode'); Logger.info('Verbose mode');
} }
@ -19,46 +33,45 @@ export default class Logger {
return this.verboseMode; return this.verboseMode;
} }
public static silentError(error: Error, ...message: any[]): string { public static silentError(error: Error, ...message: unknown[]): string {
return this.log('ERROR', message, error, true) || ''; return this.log(LogLevel.ERROR, message, error, true) || '';
} }
public static error(error: Error, ...message: any[]): string { public static error(error: Error, ...message: unknown[]): string {
return this.log('ERROR', message, error) || ''; return this.log(LogLevel.ERROR, message, error) || '';
} }
public static warn(...message: any[]) { public static warn(...message: unknown[]): void {
this.log('WARN', message); this.log(LogLevel.WARN, message);
} }
public static info(...message: any[]) { public static info(...message: unknown[]): void {
this.log('INFO', message); this.log(LogLevel.INFO, message);
} }
public static debug(...message: any[]) { public static debug(...message: unknown[]): void {
this.log('DEBUG', message); this.log(LogLevel.DEBUG, message);
} }
public static dev(...message: any[]) { public static dev(...message: unknown[]): void {
this.log('DEV', message); this.log(LogLevel.DEV, message);
} }
private static log(level: LogLevelKeys, message: any[], error?: Error, silent: boolean = false): string | null { private static log(level: LogLevel, message: unknown[], error?: Error, silent: boolean = false): string | null {
const levelIndex = LogLevel[level]; if (level <= this.logLevel) {
if (levelIndex <= LogLevel[this.logLevel]) {
if (error) { if (error) {
if (levelIndex > LogLevel.ERROR) this.warn(`Wrong log level ${level} with attached error.`); if (level > LogLevel.ERROR) this.warn(`Wrong log level ${level} with attached error.`);
} else { } else {
if (levelIndex <= LogLevel.ERROR) this.warn(`No error attached with log level ${level}.`); if (level <= LogLevel.ERROR) this.warn(`No error attached with log level ${level}.`);
} }
const computedMsg = message.map(v => { const computedMsg = message.map(v => {
if (typeof v === 'string') { if (typeof v === 'string') {
return v; return v;
} else { } else {
return JSON.stringify(v, (key: string, value: any) => { return JSON.stringify(v, (key: string, value: string | unknown[] | Record<string, unknown>) => {
if (value instanceof Object) { if (!Array.isArray(value) && value instanceof Object) {
if (value.type === 'Buffer') { if (value.type === 'Buffer' && typeof value.data === 'string') {
return `Buffer<${Buffer.from(value.data).toString('hex')}>`; return `Buffer<${Buffer.from(value.data).toString('hex')}>`;
} else if (value !== v) { } else if (value !== v) {
return `[object Object]`; return `[object Object]`;
@ -72,65 +85,56 @@ export default class Logger {
} }
}).join(' '); }).join(' ');
const shouldSaveToDB = levelIndex <= LogLevel[this.dbLogLevel]; const shouldSaveToDB = level <= this.dbLogLevel;
let output = `[${level}] `; let output = `[${level}] `;
const pad = output.length; const pad = output.length;
const logID = Buffer.alloc(16); const logId = Buffer.alloc(16);
uuid({}, logID); uuid({}, logId);
let strLogID = bufferToUUID(logID); const strLogId = bufferToUuid(logId);
if (shouldSaveToDB) output += `${strLogID} - `; if (shouldSaveToDB) output += `${strLogId} - `;
output += computedMsg.replace(/\n/g, '\n' + ' '.repeat(pad)); output += computedMsg.replace(/\n/g, '\n' + ' '.repeat(pad));
switch (level) { switch (level) {
case "ERROR": case LogLevel.ERROR:
if (silent || !error) { if (silent || !error) {
console.error(output); console.error(output);
} else { } else {
console.error(output, error); console.error(output, error);
} }
break; break;
case "WARN": case LogLevel.WARN:
console.warn(output); console.warn(output);
break; break;
case "INFO": case LogLevel.INFO:
console.info(output); console.info(output);
break; break;
case "DEBUG": case LogLevel.DEBUG:
case "DEV": case LogLevel.DEV:
console.debug(output); console.debug(output);
break; break;
} }
if (shouldSaveToDB) { if (shouldSaveToDB && ModelFactory.has(Log)) {
const log = Log.create({}); const log = Log.create({});
log.setLevel(level); log.setLevel(level);
log.message = computedMsg; log.message = computedMsg;
log.setError(error); log.setError(error);
log.setLogID(logID); log.setLogId(logId);
log.save().catch(err => { log.save().catch(err => {
if (!silent && err.message.indexOf('ECONNREFUSED') < 0) { if (!silent && err.message.indexOf('ECONNREFUSED') < 0) {
console.error({save_err: err, error}); console.error({save_err: err, error});
} }
}); });
} }
return strLogID; return strLogId;
} }
return null; return null;
} }
private constructor() { private constructor() {
// disable constructor
} }
} }
export enum LogLevel {
ERROR,
WARN,
INFO,
DEBUG,
DEV,
}
export type LogLevelKeys = keyof typeof LogLevel;

View File

@ -7,9 +7,10 @@ import {WrappingError} from "./Utils";
import mjml2html from "mjml"; import mjml2html from "mjml";
import Logger from "./Logger"; import Logger from "./Logger";
import Controller from "./Controller"; import Controller from "./Controller";
import {ParsedUrlQueryInput} from "querystring";
export default class Mail { export default class Mail {
private static transporter: Transporter; private static transporter?: Transporter;
private static getTransporter(): Transporter { private static getTransporter(): Transporter {
if (!this.transporter) throw new MailError('Mail system was not prepared.'); if (!this.transporter) throw new MailError('Mail system was not prepared.');
@ -26,8 +27,8 @@ export default class Mail {
pass: config.get('mail.password'), pass: config.get('mail.password'),
}, },
tls: { tls: {
rejectUnauthorized: !config.get('mail.allow_invalid_tls') rejectUnauthorized: !config.get('mail.allow_invalid_tls'),
} },
}); });
try { try {
@ -40,11 +41,11 @@ export default class Mail {
Logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`); Logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`);
} }
public static end() { public static end(): void {
if (this.transporter) this.transporter.close(); if (this.transporter) this.transporter.close();
} }
public static parse(template: string, data: any, textOnly: boolean): string { public static parse(template: string, data: { [p: string]: unknown }, textOnly: boolean): string {
data.text = textOnly; data.text = textOnly;
const nunjucksResult = nunjucks.render(template, data); const nunjucksResult = nunjucks.render(template, data);
if (textOnly) return nunjucksResult; if (textOnly) return nunjucksResult;
@ -60,9 +61,9 @@ export default class Mail {
private readonly template: MailTemplate; private readonly template: MailTemplate;
private readonly options: Options = {}; private readonly options: Options = {};
private readonly data: { [p: string]: any }; private readonly data: ParsedUrlQueryInput;
constructor(template: MailTemplate, data: { [p: string]: any } = {}) { public constructor(template: MailTemplate, data: ParsedUrlQueryInput = {}) {
this.template = template; this.template = template;
this.data = data; this.data = data;
this.options.subject = this.template.getSubject(data); this.options.subject = this.template.getSubject(data);
@ -97,7 +98,8 @@ export default class Mail {
// Set data // Set data
this.data.mail_subject = this.options.subject; this.data.mail_subject = this.options.subject;
this.data.mail_to = this.options.to; this.data.mail_to = this.options.to;
this.data.mail_link = config.get<string>('base_url') + Controller.route('mail', [this.template.template], this.data); this.data.mail_link = config.get<string>('base_url') +
Controller.route('mail', [this.template.template], this.data);
this.data.app = config.get('app'); this.data.app = config.get('app');
// Log // Log
@ -117,9 +119,9 @@ export default class Mail {
export class MailTemplate { export class MailTemplate {
private readonly _template: string; private readonly _template: string;
private readonly subject: (data: any) => string; private readonly subject: (data: { [p: string]: unknown }) => string;
constructor(template: string, subject: (data: any) => string) { public constructor(template: string, subject: (data: { [p: string]: unknown }) => string) {
this._template = template; this._template = template;
this.subject = subject; this.subject = subject;
} }
@ -128,13 +130,13 @@ export class MailTemplate {
return this._template; return this._template;
} }
public getSubject(data: any): string { public getSubject(data: { [p: string]: unknown }): string {
return `${config.get('app.name')} - ${this.subject(data)}`; return `${config.get('app.name')} - ${this.subject(data)}`;
} }
} }
class MailError extends WrappingError { class MailError extends WrappingError {
constructor(message: string = 'An error occurred while sending mail.', cause?: Error) { public constructor(message: string = 'An error occurred while sending mail.', cause?: Error) {
super(message, cause); super(message, cause);
} }
} }

View File

@ -3,12 +3,12 @@ import {MailTemplate} from "./Mail";
export const MAGIC_LINK_MAIL = new MailTemplate( export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link', 'magic_link',
data => data.type === 'register' ? 'Registration' : 'Login magic link' data => data.type === 'register' ? 'Registration' : 'Login magic link',
); );
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate( export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'account_review_notice', 'account_review_notice',
data => `Your account was ${data.approved ? 'approved' : 'rejected'}.` data => `Your account was ${data.approved ? 'approved' : 'rejected'}.`,
); );
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate( export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(

View File

@ -1,6 +1,7 @@
import {RequestHandler} from "express"; import {RequestHandler} from "express";
import {NextFunction, Request, Response} from "express-serve-static-core"; import {NextFunction, Request, Response} from "express-serve-static-core";
import Application from "./Application"; import Application from "./Application";
import {Type} from "./Utils";
export default abstract class Middleware { export default abstract class Middleware {
public constructor( public constructor(
@ -26,3 +27,7 @@ export default abstract class Middleware {
}; };
} }
} }
export interface MiddlewareType<M extends Middleware> extends Type<M> {
new(app: Application): M;
}

View File

@ -6,7 +6,7 @@ export default class Pagination<T extends Model> {
public readonly perPage: number; public readonly perPage: number;
public readonly totalCount: number; public readonly totalCount: number;
constructor(models: T[], page: number, perPage: number, totalCount: number) { public constructor(models: T[], page: number, perPage: number, totalCount: number) {
this.models = models; this.models = models;
this.page = page; this.page = page;
this.perPage = perPage; this.perPage = perPage;

View File

@ -1,7 +1,7 @@
import {TooManyRequestsHttpError} from "./HttpError"; import {TooManyRequestsHttpError} from "./HttpError";
export default class Throttler { export default class Throttler {
private static readonly throttles: { [throttleName: string]: Throttle } = {}; private static readonly throttles: Record<string, Throttle | undefined> = {};
/** /**
* Throttle function; will throw a TooManyRequestsHttpError when the threshold is reached. * Throttle function; will throw a TooManyRequestsHttpError when the threshold is reached.
@ -16,13 +16,21 @@ export default class Throttler {
* @param holdPeriod time in ms after each call before the threshold begins to decrease. * @param holdPeriod time in ms after each call before the threshold begins to decrease.
* @param jailPeriod time in ms for which the throttle will throw when it is triggered. * @param jailPeriod time in ms for which the throttle will throw when it is triggered.
*/ */
public static throttle(action: string, max: number, resetPeriod: number, id: string, holdPeriod: number = 100, jailPeriod: number = 30 * 1000) { public static throttle(
action: string,
max: number,
resetPeriod: number,
id: string,
holdPeriod: number = 100,
jailPeriod: number = 30 * 1000,
): void {
let throttle = this.throttles[action]; let throttle = this.throttles[action];
if (!throttle) throttle = this.throttles[action] = new Throttle(max, resetPeriod, holdPeriod, jailPeriod); if (!throttle) throttle = this.throttles[action] = new Throttle(max, resetPeriod, holdPeriod, jailPeriod);
throttle.trigger(id); throttle.trigger(id);
} }
private constructor() { private constructor() {
// Disable constructor
} }
} }
@ -31,15 +39,13 @@ class Throttle {
private readonly resetPeriod: number; private readonly resetPeriod: number;
private readonly holdPeriod: number; private readonly holdPeriod: number;
private readonly jailPeriod: number; private readonly jailPeriod: number;
private readonly triggers: { private readonly triggers: Record<string, {
[id: string]: {
count: number, count: number,
lastTrigger?: number, lastTrigger?: number,
jailed?: number; jailed?: number;
} } | undefined> = {};
} = {};
constructor(max: number, resetPeriod: number, holdPeriod: number, jailPeriod: number) { public constructor(max: number, resetPeriod: number, holdPeriod: number, jailPeriod: number) {
this.max = max; this.max = max;
this.resetPeriod = resetPeriod; this.resetPeriod = resetPeriod;
this.holdPeriod = holdPeriod; this.holdPeriod = holdPeriod;
@ -51,12 +57,10 @@ class Throttle {
let trigger = this.triggers[id]; let trigger = this.triggers[id];
if (!trigger) trigger = this.triggers[id] = {count: 0}; if (!trigger) trigger = this.triggers[id] = {count: 0};
let currentDate = new Date().getTime(); const currentDate = new Date().getTime();
if (trigger.jailed && currentDate - trigger.jailed < this.jailPeriod) { if (trigger.jailed && currentDate - trigger.jailed < this.jailPeriod)
this.throw((trigger.jailed + this.jailPeriod) - currentDate); return this.throw(trigger.jailed + this.jailPeriod - currentDate);
return;
}
if (trigger.lastTrigger) { if (trigger.lastTrigger) {
let timeDiff = currentDate - trigger.lastTrigger; let timeDiff = currentDate - trigger.lastTrigger;
@ -71,8 +75,7 @@ class Throttle {
if (trigger.count > this.max) { if (trigger.count > this.max) {
trigger.jailed = currentDate; trigger.jailed = currentDate;
this.throw((trigger.jailed + this.jailPeriod) - currentDate); return this.throw(trigger.jailed + this.jailPeriod - currentDate);
return;
} }
} }

View File

@ -18,7 +18,7 @@ export abstract class WrappingError extends Error {
} }
} }
get name(): string { public get name(): string {
return this.constructor.name; return this.constructor.name;
} }
} }
@ -28,15 +28,15 @@ export function cryptoRandomDictionary(size: number, dictionary: string): string
const output = new Array(size); const output = new Array(size);
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
output[i] = dictionary[Math.floor((randomBytes[i] / 255) * dictionary.length)]; output[i] = dictionary[Math.floor(randomBytes[i] / 255 * dictionary.length)];
} }
return output.join(''); return output.join('');
} }
export type Type<T> = { new(...args: any[]): T }; export type Type<T> = { new(...args: never[]): T };
export function bufferToUUID(buffer: Buffer): string { export function bufferToUuid(buffer: Buffer): string {
const chars = buffer.toString('hex'); const chars = buffer.toString('hex');
let out = ''; let out = '';
let i = 0; let i = 0;
@ -48,12 +48,12 @@ export function bufferToUUID(buffer: Buffer): string {
return out; return out;
} }
export function getMethods<T>(obj: T): (string)[] { export function getMethods<T extends { [p: string]: unknown }>(obj: T): string[] {
let properties = new Set() const properties = new Set<string>();
let currentObj = obj let currentObj: T | unknown = obj;
do { do {
Object.getOwnPropertyNames(currentObj).map(item => properties.add(item)) Object.getOwnPropertyNames(currentObj).map(item => properties.add(item));
} while ((currentObj = Object.getPrototypeOf(currentObj))) currentObj = Object.getPrototypeOf(currentObj);
// @ts-ignore } while (currentObj);
return [...properties.keys()].filter(item => typeof obj[item] === 'function') return [...properties.keys()].filter(item => typeof obj[item] === 'function');
} }

View File

@ -15,5 +15,9 @@ export default abstract class WebSocketListener<T extends Application> {
public abstract path(): string; public abstract path(): string;
public abstract async handle(socket: WebSocket, request: IncomingMessage, session: Express.Session | null): Promise<void>; public abstract async handle(
socket: WebSocket,
request: IncomingMessage,
session: Express.Session | null,
): Promise<void>;
} }

View File

@ -31,7 +31,7 @@ export class AuthMiddleware extends Middleware {
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> { protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
this.authGuard = this.app.as(AuthComponent).getAuthGuard(); this.authGuard = this.app.as(AuthComponent).getAuthGuard();
const proof = await this.authGuard.isAuthenticated(req.session!); const proof = await this.authGuard.isAuthenticated(req.getSession());
if (proof) { if (proof) {
this.user = await proof.getResource(); this.user = await proof.getResource();
res.locals.user = this.user; res.locals.user = this.user;
@ -76,7 +76,7 @@ export class RequireAuthMiddleware extends Middleware {
} }
// Via session // Via session
if (!await authGuard.isAuthenticated(req.session!)) { if (!await authGuard.isAuthenticated(req.getSession())) {
req.flash('error', `You must be logged in to access ${req.url}.`); req.flash('error', `You must be logged in to access ${req.url}.`);
res.redirect(Controller.route('auth', undefined, { res.redirect(Controller.route('auth', undefined, {
redirect_uri: req.url, redirect_uri: req.url,
@ -90,7 +90,7 @@ export class RequireAuthMiddleware extends Middleware {
export class RequireGuestMiddleware extends Middleware { export class RequireGuestMiddleware extends Middleware {
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> { protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
if (await req.as(AuthMiddleware).getAuthGuard().isAuthenticated(req.session!)) { if (await req.as(AuthMiddleware).getAuthGuard().isAuthenticated(req.getSession())) {
res.redirectBack(); res.redirectBack();
return; return;
} }

View File

@ -7,14 +7,14 @@ export default abstract class AuthController extends Controller {
return '/auth'; return '/auth';
} }
public routes() { public routes(): void {
this.get('/', this.getAuth, 'auth', RequireGuestMiddleware); this.get('/', this.getAuth, 'auth', RequireGuestMiddleware);
this.post('/', this.postAuth, 'auth', RequireGuestMiddleware); this.post('/', this.postAuth, 'auth', RequireGuestMiddleware);
this.get('/check', this.getCheckAuth, 'check_auth'); this.get('/check', this.getCheckAuth, 'check_auth');
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware); this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
} }
protected async getAuth(req: Request, res: Response, next: NextFunction): Promise<void> { protected async getAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const registerEmail = req.flash('register_confirm_email'); const registerEmail = req.flash('register_confirm_email');
res.render('auth/auth', { res.render('auth/auth', {
register_confirm_email: registerEmail.length > 0 ? registerEmail[0] : null, register_confirm_email: registerEmail.length > 0 ? registerEmail[0] : null,
@ -25,7 +25,7 @@ export default abstract class AuthController extends Controller {
protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void>; protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void>;
protected async postLogout(req: Request, res: Response, next: NextFunction): Promise<void> { protected async postLogout(req: Request, res: Response, _next: NextFunction): Promise<void> {
const proof = await req.as(AuthMiddleware).getAuthGuard().getProof(req); const proof = await req.as(AuthMiddleware).getAuthGuard().getProof(req);
await proof?.revoke(); await proof?.revoke();
req.flash('success', 'Successfully logged out.'); req.flash('success', 'Successfully logged out.');

View File

@ -11,7 +11,7 @@ import config from "config";
export default abstract class AuthGuard<P extends AuthProof<User>> { export default abstract class AuthGuard<P extends AuthProof<User>> {
protected abstract async getProofForSession(session: Express.Session): Promise<P | null>; protected abstract async getProofForSession(session: Express.Session): Promise<P | null>;
protected async getProofForRequest(req: Request): Promise<P | null> { protected async getProofForRequest(_req: Request): Promise<P | null> {
return null; return null;
} }
@ -52,7 +52,7 @@ export default abstract class AuthGuard<P extends AuthProof<User>> {
session: Express.Session, session: Express.Session,
proof: P, proof: P,
onLogin?: (user: User) => Promise<void>, onLogin?: (user: User) => Promise<void>,
onRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]> onRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
): Promise<User> { ): Promise<User> {
if (!await proof.isValid()) throw new InvalidAuthProofError(); if (!await proof.isValid()) throw new InvalidAuthProofError();
if (!await proof.isAuthorized()) throw new UnauthorizedAuthProofError(); if (!await proof.isAuthorized()) throw new UnauthorizedAuthProofError();
@ -63,28 +63,25 @@ export default abstract class AuthGuard<P extends AuthProof<User>> {
if (!user) { if (!user) {
const callbacks: RegisterCallback[] = []; const callbacks: RegisterCallback[] = [];
await MysqlConnectionManager.wrapTransaction(async connection => { user = await MysqlConnectionManager.wrapTransaction(async connection => {
user = User.create({}); const user = User.create({});
await user.save(connection, c => callbacks.push(c)); await user.save(connection, c => callbacks.push(c));
if (onRegister) { if (onRegister) {
(await onRegister(connection, user)).forEach(c => callbacks.push(c)); (await onRegister(connection, user)).forEach(c => callbacks.push(c));
} }
return user;
}); });
for (const callback of callbacks) { for (const callback of callbacks) {
await callback(); await callback();
} }
if (user) { if (!user.isApproved()) {
if (!user!.isApproved()) {
await new Mail(PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, { await new Mail(PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: user!.name, username: (await user.mainEmail.get())?.getOrFail('email'),
link: config.get<string>('base_url') + Controller.route('accounts-approval'), link: config.get<string>('base_url') + Controller.route('accounts-approval'),
}).send(config.get<string>('app.contact_email')); }).send(config.get<string>('app.contact_email'));
} }
} else {
throw new Error('Unable to register user.');
}
} }
// Don't login if user isn't approved // Don't login if user isn't approved
@ -102,28 +99,25 @@ export default abstract class AuthGuard<P extends AuthProof<User>> {
} }
export class AuthError extends Error { export class AuthError extends Error {
constructor(message: string) {
super(message);
}
} }
export class AuthProofError extends AuthError { export class AuthProofError extends AuthError {
} }
export class InvalidAuthProofError extends AuthProofError { export class InvalidAuthProofError extends AuthProofError {
constructor() { public constructor() {
super('Invalid auth proof.'); super('Invalid auth proof.');
} }
} }
export class UnauthorizedAuthProofError extends AuthProofError { export class UnauthorizedAuthProofError extends AuthProofError {
constructor() { public constructor() {
super('Unauthorized auth proof.'); super('Unauthorized auth proof.');
} }
} }
export class PendingApprovalAuthError extends AuthError { export class PendingApprovalAuthError extends AuthError {
constructor() { public constructor() {
super(`User is not approved.`); super(`User is not approved.`);
} }
} }

View File

@ -2,8 +2,8 @@
* This class is most commonly used for authentication. It can be more generically used to represent a verification * This class is most commonly used for authentication. It can be more generically used to represent a verification
* state of whether a given resource is owned by a session. * state of whether a given resource is owned by a session.
* *
* Any auth system should consider this auth proof valid if and only if both {@code isValid()} and {@code isAuthorized()} * Any auth system should consider this auth proof valid if and only if both {@code isValid()} and
* both return {@code true}. * {@code isAuthorized()} both return {@code true}.
* *
* @type <R> The resource type this AuthProof authorizes. * @type <R> The resource type this AuthProof authorizes.
*/ */
@ -34,7 +34,8 @@ export default interface AuthProof<R> {
* - {@code isAuthorized} returns {@code false} * - {@code isAuthorized} returns {@code false}
* - There is no way to re-authorize this proof (i.e. {@code isAuthorized} can never return {@code true} again) * - There is no way to re-authorize this proof (i.e. {@code isAuthorized} can never return {@code true} again)
* *
* Additionally, this method should delete any stored data that could lead to restoration of this AuthProof instance. * Additionally, this method should delete any stored data that could lead to restoration of this AuthProof
* instance.
*/ */
revoke(): Promise<void>; revoke(): Promise<void>;
} }

View File

@ -3,11 +3,11 @@ import Controller from "../Controller";
import Mail from "../Mail"; import Mail from "../Mail";
export default class MailController extends Controller { export default class MailController extends Controller {
routes(): void { public routes(): void {
this.get("/mail/:template", this.getMail, 'mail'); this.get("/mail/:template", this.getMail, 'mail');
} }
private async getMail(request: Request, response: Response) { protected async getMail(request: Request, response: Response): Promise<void> {
const template = request.params['template']; const template = request.params['template'];
response.send(Mail.parse(`mails/${template}.mjml.njk`, request.query, false)); response.send(Mail.parse(`mails/${template}.mjml.njk`, request.query, false));
} }

View File

@ -15,18 +15,20 @@ import User from "../models/User";
export default abstract class MagicLinkAuthController extends AuthController { export default abstract class MagicLinkAuthController extends AuthController {
public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise<User | null> { public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise<User | null> {
if (magicLink.getSessionID() !== req.sessionID!) throw new BadOwnerMagicLink(); const session = req.getSession();
if (magicLink.getOrFail('session_id') !== session.id) throw new BadOwnerMagicLink();
if (!await magicLink.isAuthorized()) throw new UnauthorizedMagicLink(); if (!await magicLink.isAuthorized()) throw new UnauthorizedMagicLink();
if (!await magicLink.isValid()) throw new InvalidMagicLink(); if (!await magicLink.isValid()) throw new InvalidMagicLink();
// Auth // Auth
try { try {
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(req.session!, magicLink, undefined, async (connection, user) => { return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
session, magicLink, undefined, async (connection, user) => {
const callbacks: RegisterCallback[] = []; const callbacks: RegisterCallback[] = [];
const userEmail = UserEmail.create({ const userEmail = UserEmail.create({
user_id: user.id, user_id: user.id,
email: magicLink.getEmail(), email: magicLink.getOrFail('email'),
}); });
await userEmail.save(connection, c => callbacks.push(c)); await userEmail.save(connection, c => callbacks.push(c));
user.main_email_id = userEmail.id; user.main_email_id = userEmail.id;
@ -40,13 +42,13 @@ export default abstract class MagicLinkAuthController extends AuthController {
json: () => { json: () => {
res.json({ res.json({
'status': 'warning', 'status': 'warning',
'message': `Your account is pending review. You'll receive an email once you're approved.` 'message': `Your account is pending review. You'll receive an email once you're approved.`,
}); });
}, },
html: () => { html: () => {
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`); req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
res.redirect('/'); res.redirect('/');
} },
}); });
return null; return null;
} else { } else {
@ -65,7 +67,8 @@ export default abstract class MagicLinkAuthController extends AuthController {
} }
protected async getAuth(req: Request, res: Response, next: NextFunction): Promise<void> { protected async getAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const link = await MagicLink.bySessionID(req.sessionID!, [this.loginMagicLinkActionType, this.registerMagicLinkActionType]); const link = await MagicLink.bySessionId(req.getSession().id,
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
if (link && await link.isValid()) { if (link && await link.isValid()) {
res.redirect(Controller.route('magic_link_lobby', undefined, { res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined, redirect_uri: req.query.redirect_uri?.toString() || undefined,
@ -76,7 +79,7 @@ export default abstract class MagicLinkAuthController extends AuthController {
await super.getAuth(req, res, next); await super.getAuth(req, res, next);
} }
protected async postAuth(req: Request, res: Response, next: NextFunction): Promise<void> { protected async postAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const email = req.body.email; const email = req.body.email;
if (!email) throw new BadRequestError('Email not specified.', 'Please try again.', req.originalUrl); if (!email) throw new BadRequestError('Email not specified.', 'Please try again.', req.originalUrl);
@ -96,7 +99,7 @@ export default abstract class MagicLinkAuthController extends AuthController {
// Register (email link) // Register (email link)
const geo = geoip.lookup(req.ip); const geo = geoip.lookup(req.ip);
await MagicLinkController.sendMagicLink( await MagicLinkController.sendMagicLink(
req.sessionID!, req.getSession().id,
isRegistration ? this.registerMagicLinkActionType : this.loginMagicLinkActionType, isRegistration ? this.registerMagicLinkActionType : this.loginMagicLinkActionType,
Controller.route('auth', undefined, { Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined, redirect_uri: req.query.redirect_uri?.toString() || undefined,
@ -107,7 +110,7 @@ export default abstract class MagicLinkAuthController extends AuthController {
type: isRegistration ? 'register' : 'login', type: isRegistration ? 'register' : 'login',
ip: req.ip, ip: req.ip,
geo: geo ? `${geo.city}, ${geo.country}` : 'Unknown location', geo: geo ? `${geo.city}, ${geo.country}` : 'Unknown location',
} },
); );
res.redirect(Controller.route('magic_link_lobby', undefined, { res.redirect(Controller.route('magic_link_lobby', undefined, {
@ -125,13 +128,18 @@ export default abstract class MagicLinkAuthController extends AuthController {
/** /**
* Check whether a magic link is authorized, and authenticate if yes * Check whether a magic link is authorized, and authenticate if yes
*/ */
protected async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void> { protected async getCheckAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const magicLink = await MagicLink.bySessionID(req.sessionID!, [this.loginMagicLinkActionType, this.registerMagicLinkActionType]); const magicLink = await MagicLink.bySessionId(req.getSession().id,
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
if (!magicLink) { if (!magicLink) {
res.format({ res.format({
json: () => { json: () => {
throw new BadRequestError('No magic link were found linked with that session.', 'Please retry once you have requested a magic link.', req.originalUrl); throw new BadRequestError(
'No magic link were found linked with that session.',
'Please retry once you have requested a magic link.',
req.originalUrl,
);
}, },
default: () => { default: () => {
req.flash('warning', 'No magic link found. Please try again.'); req.flash('warning', 'No magic link found. Please try again.');
@ -160,19 +168,19 @@ export default abstract class MagicLinkAuthController extends AuthController {
} }
export class BadOwnerMagicLink extends AuthError { export class BadOwnerMagicLink extends AuthError {
constructor() { public constructor() {
super(`This magic link doesn't belong to this session.`); super(`This magic link doesn't belong to this session.`);
} }
} }
export class UnauthorizedMagicLink extends AuthError { export class UnauthorizedMagicLink extends AuthError {
constructor() { public constructor() {
super(`This magic link is unauthorized.`); super(`This magic link is unauthorized.`);
} }
} }
export class InvalidMagicLink extends AuthError { export class InvalidMagicLink extends AuthError {
constructor() { public constructor() {
super(`This magic link is invalid.`); super(`This magic link is invalid.`);
} }
} }

View File

@ -6,15 +6,24 @@ import Throttler from "../../Throttler";
import Mail, {MailTemplate} from "../../Mail"; import Mail, {MailTemplate} from "../../Mail";
import MagicLink from "../models/MagicLink"; import MagicLink from "../models/MagicLink";
import config from "config"; import config from "config";
import Application from "../../Application";
import {ParsedUrlQueryInput} from "querystring";
export default abstract class MagicLinkController extends Controller { export default abstract class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink(sessionID: string, actionType: string, original_url: string, email: string, mailTemplate: MailTemplate, data: object): Promise<void> { public static async sendMagicLink(
Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionID, 0, 0); sessionId: string,
actionType: string,
original_url: string,
email: string,
mailTemplate: MailTemplate,
data: ParsedUrlQueryInput,
): Promise<void> {
Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0);
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0); Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0);
const link = await MagicLink.bySessionID(sessionID, actionType) || const link = await MagicLink.bySessionId(sessionId, actionType) ||
MagicLink.create({ MagicLink.create({
session_id: sessionID, session_id: sessionId,
action_type: actionType, action_type: actionType,
original_url: original_url, original_url: original_url,
}); });
@ -34,7 +43,7 @@ export default abstract class MagicLinkController extends Controller {
protected readonly magicLinkWebsocketPath: string; protected readonly magicLinkWebsocketPath: string;
protected constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener) { protected constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
super(); super();
this.magicLinkWebsocketPath = magicLinkWebsocketListener.path(); this.magicLinkWebsocketPath = magicLinkWebsocketListener.path();
} }
@ -49,14 +58,14 @@ export default abstract class MagicLinkController extends Controller {
} }
protected async getLobby(req: Request, res: Response): Promise<void> { protected async getLobby(req: Request, res: Response): Promise<void> {
const link = await MagicLink.bySessionID(req.sessionID!); const link = await MagicLink.bySessionId(req.getSession().id);
if (!link) { if (!link) {
throw new NotFoundHttpError('magic link', req.url); throw new NotFoundHttpError('magic link', req.url);
} }
if (!await link.isValid()) { if (!await link.isValid()) {
req.flash('error', 'This magic link has expired. Please try again.'); req.flash('error', 'This magic link has expired. Please try again.');
res.redirect(link.getOriginalURL()); res.redirect(link.getOrFail('original_url'));
return; return;
} }
@ -66,8 +75,8 @@ export default abstract class MagicLinkController extends Controller {
} }
res.render('magic_link_lobby', { res.render('magic_link_lobby', {
email: link.getEmail(), email: link.getOrFail('email'),
type: link.getActionType(), type: link.getOrFail('action_type'),
validUntil: link.getExpirationDate().getTime(), validUntil: link.getExpirationDate().getTime(),
websocketUrl: config.get<string>('public_websocket_url') + this.magicLinkWebsocketPath, websocketUrl: config.get<string>('public_websocket_url') + this.magicLinkWebsocketPath,
}); });
@ -76,7 +85,8 @@ export default abstract class MagicLinkController extends Controller {
protected async getMagicLink(req: Request, res: Response): Promise<void> { protected async getMagicLink(req: Request, res: Response): Promise<void> {
const id = parseInt(<string>req.query.id); const id = parseInt(<string>req.query.id);
const token = <string>req.query.token; const token = <string>req.query.token;
if (!id || !token) throw new BadRequestError('Need parameters id, token.', 'Please try again.', req.originalUrl); if (!id || !token)
throw new BadRequestError('Need parameters id, token.', 'Please try again.', req.originalUrl);
let success = true; let success = true;
let err; let err;
@ -91,7 +101,8 @@ export default abstract class MagicLinkController extends Controller {
// Validation success, authenticate the user // Validation success, authenticate the user
magicLink.authorize(); magicLink.authorize();
await magicLink.save(); await magicLink.save();
MagicLinkWebSocketListener.refreshMagicLink(magicLink.getSessionID()); this.getApp().as<MagicLinkWebSocketListener<A>>(MagicLinkWebSocketListener)
.refreshMagicLink(magicLink.getOrFail('session_id'));
} }
} }

View File

@ -2,18 +2,19 @@ import WebSocket from "ws";
import {IncomingMessage} from "http"; import {IncomingMessage} from "http";
import WebSocketListener from "../../WebSocketListener"; import WebSocketListener from "../../WebSocketListener";
import MagicLink from "../models/MagicLink"; import MagicLink from "../models/MagicLink";
import Application from "../../Application";
export default class MagicLinkWebSocketListener extends WebSocketListener<any> { export default class MagicLinkWebSocketListener<A extends Application> extends WebSocketListener<A> {
private static readonly connections: { [p: string]: (() => void)[] } = {}; private readonly connections: { [p: string]: (() => void)[] | undefined } = {};
public static refreshMagicLink(sessionID: string) { public refreshMagicLink(sessionId: string): void {
const fs = MagicLinkWebSocketListener.connections[sessionID]; const fs = this.connections[sessionId];
if (fs) { if (fs) {
fs.forEach(f => f()); fs.forEach(f => f());
} }
} }
async handle(socket: WebSocket, request: IncomingMessage, session: Express.Session | null): Promise<void> { public async handle(socket: WebSocket, request: IncomingMessage, session: Express.Session | null): Promise<void> {
// Drop if requested without session // Drop if requested without session
if (!session) { if (!session) {
socket.close(1002, 'Session is required for this request.'); socket.close(1002, 'Session is required for this request.');
@ -26,7 +27,7 @@ export default class MagicLinkWebSocketListener extends WebSocketListener<any> {
}); });
// Get magic link // Get magic link
const magicLink = await MagicLink.bySessionID(session.id); const magicLink = await MagicLink.bySessionId(session.id);
// Refresh if immediately applicable // Refresh if immediately applicable
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) { if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
@ -47,16 +48,19 @@ export default class MagicLinkWebSocketListener extends WebSocketListener<any> {
}; };
socket.on('close', () => { socket.on('close', () => {
MagicLinkWebSocketListener.connections[session.id] = MagicLinkWebSocketListener.connections[session.id].filter(f => f !== f); const connections = this.connections[session.id];
if (MagicLinkWebSocketListener.connections[session.id].length === 0) delete MagicLinkWebSocketListener.connections[session.id]; if (connections) {
this.connections[session.id] = connections.filter(f => f !== f);
if (connections.length === 0) delete this.connections[session.id];
}
}); });
if (!MagicLinkWebSocketListener.connections[session.id]) MagicLinkWebSocketListener.connections[session.id] = []; let connections = this.connections[session.id];
if (!connections) connections = this.connections[session.id] = [];
MagicLinkWebSocketListener.connections[session.id].push(f); connections.push(f);
} }
path(): string { public path(): string {
return '/magic-link'; return '/magic-link';
} }
} }

View File

@ -10,6 +10,4 @@ export default class DropNameFromUsers extends Migration {
await this.query('ALTER TABLE users ADD COLUMN name VARCHAR(64)', connection); await this.query('ALTER TABLE users ADD COLUMN name VARCHAR(64)', connection);
} }
public registerModels(): void {
}
} }

View File

@ -17,13 +17,9 @@ export default class FixUserMainEmailRelation extends Migration {
await this.query(`ALTER TABLE user_emails await this.query(`ALTER TABLE user_emails
ADD COLUMN main BOOLEAN DEFAULT false`, connection); ADD COLUMN main BOOLEAN DEFAULT false`, connection);
await this.query(`UPDATE user_emails ue LEFT JOIN users u ON ue.id = u.main_email_id await this.query(`UPDATE user_emails ue LEFT JOIN users u ON ue.id = u.main_email_id
SET ue.main = true`, connection) SET ue.main = true`, connection);
await this.query(`ALTER TABLE users await this.query(`ALTER TABLE users
DROP FOREIGN KEY main_user_email_fk, DROP FOREIGN KEY main_user_email_fk,
DROP COLUMN main_email_id`, connection); DROP COLUMN main_email_id`, connection);
} }
public registerModels(): void {
}
} }

View File

@ -9,8 +9,8 @@ import UserEmail from "./UserEmail";
import {EMAIL_REGEX} from "../../db/Validator"; import {EMAIL_REGEX} from "../../db/Validator";
export default class MagicLink extends Model implements AuthProof<User> { export default class MagicLink extends Model implements AuthProof<User> {
public static async bySessionID(sessionID: string, actionType?: string | string[]): Promise<MagicLink | null> { public static async bySessionId(sessionId: string, actionType?: string | string[]): Promise<MagicLink | null> {
let query = this.select().where('session_id', sessionID); let query = this.select().where('session_id', sessionId);
if (actionType !== undefined) { if (actionType !== undefined) {
if (typeof actionType === 'string') { if (typeof actionType === 'string') {
query = query.where('action_type', actionType); query = query.where('action_type', actionType);
@ -26,10 +26,10 @@ export default class MagicLink extends Model implements AuthProof<User> {
} }
public readonly id?: number = undefined; public readonly id?: number = undefined;
private session_id?: string = undefined; public readonly session_id?: string = undefined;
private email?: string = undefined; private email?: string = undefined;
private token?: string = undefined; private token?: string = undefined;
private action_type?: string = undefined; public readonly action_type?: string = undefined;
private original_url?: string = undefined; private original_url?: string = undefined;
private generated_at?: Date = undefined; private generated_at?: Date = undefined;
private authorized: boolean = false; private authorized: boolean = false;
@ -46,9 +46,9 @@ export default class MagicLink extends Model implements AuthProof<User> {
public async getResource(): Promise<User | null> { public async getResource(): Promise<User | null> {
const email = await UserEmail.select() const email = await UserEmail.select()
.with('user') .with('user')
.where('email', await this.getEmail()) .where('email', await this.getOrFail('email'))
.first(); .first();
return email ? email.user.get() : null; return email ? await email.user.get() : null;
} }
public async revoke(): Promise<void> { public async revoke(): Promise<void> {
@ -61,10 +61,10 @@ export default class MagicLink extends Model implements AuthProof<User> {
} }
public async isAuthorized(): Promise<boolean> { public async isAuthorized(): Promise<boolean> {
return this.authorized!; return this.authorized;
} }
public authorize() { public authorize(): void {
this.authorized = true; this.authorized = true;
} }
@ -85,28 +85,21 @@ export default class MagicLink extends Model implements AuthProof<User> {
* @returns {@code null} if the token is valid, an error {@code string} otherwise. * @returns {@code null} if the token is valid, an error {@code string} otherwise.
*/ */
public async verifyToken(tokenGuess: string): Promise<string | null> { public async verifyToken(tokenGuess: string): Promise<string | null> {
if (this.token === undefined || this.generated_at === undefined) return 'This token was not generated.'; // There is no token // There is no token
if (new Date().getTime() - this.generated_at.getTime() > MagicLink.validityPeriod()) return 'This token has expired.'; // Token has expired if (this.token === undefined || this.generated_at === undefined)
if (!await argon2.verify(this.token, tokenGuess)) return 'This token is invalid.'; return 'This token was not generated.';
// Token has expired
if (new Date().getTime() - this.generated_at.getTime() > MagicLink.validityPeriod())
return 'This token has expired.';
// Token is invalid
if (!await argon2.verify(this.token, tokenGuess))
return 'This token is invalid.';
return null; return null;
} }
public getSessionID(): string {
return this.session_id!;
}
public getEmail(): string {
return this.email!;
}
public getActionType(): string {
return this.action_type!;
}
public getOriginalURL(): string {
return this.original_url!;
}
public getExpirationDate(): Date { public getExpirationDate(): Date {
if (!this.generated_at) return new Date(); if (!this.generated_at) return new Date();

View File

@ -8,7 +8,8 @@ import UserApprovedComponent from "./UserApprovedComponent";
export default class User extends Model { export default class User extends Model {
public static isApprovalMode(): boolean { public static isApprovalMode(): boolean {
return config.get<boolean>('approval_mode') && MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable); return config.get<boolean>('approval_mode') &&
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable);
} }
public readonly id?: number = undefined; public readonly id?: number = undefined;
@ -19,7 +20,7 @@ export default class User extends Model {
public readonly emails = new ManyModelRelation(this, UserEmail, { public readonly emails = new ManyModelRelation(this, UserEmail, {
localKey: 'id', localKey: 'id',
foreignKey: 'user_id' foreignKey: 'user_id',
}); });
public readonly mainEmail = this.emails.cloneReduceToOne().constraint(q => q.where('id', this.main_email_id)); public readonly mainEmail = this.emails.cloneReduceToOne().constraint(q => q.where('id', this.main_email_id));

View File

@ -3,7 +3,4 @@ import User from "./User";
export default class UserApprovedComponent extends ModelComponent<User> { export default class UserApprovedComponent extends ModelComponent<User> {
public approved: boolean = false; public approved: boolean = false;
protected init(): void {
}
} }

View File

@ -11,7 +11,7 @@ export default class UserEmail extends Model {
public readonly user = new OneModelRelation(this, User, { public readonly user = new OneModelRelation(this, User, {
localKey: 'user_id', localKey: 'user_id',
foreignKey: 'id' foreignKey: 'id',
}); });
protected init(): void { protected init(): void {

View File

@ -6,6 +6,11 @@ import {ForbiddenHttpError} from "../HttpError";
import Logger from "../Logger"; import Logger from "../Logger";
export default class AutoUpdateComponent extends ApplicationComponent { export default class AutoUpdateComponent extends ApplicationComponent {
private static async runCommand(command: string): Promise<void> {
Logger.info(`> ${command}`);
Logger.info(child_process.execSync(command).toString());
}
public async checkSecuritySettings(): Promise<void> { public async checkSecuritySettings(): Promise<void> {
this.checkSecurityConfigField('gitlab_webhook_token'); this.checkSecurityConfigField('gitlab_webhook_token');
} }
@ -13,7 +18,8 @@ export default class AutoUpdateComponent extends ApplicationComponent {
public async init(router: Router): Promise<void> { public async init(router: Router): Promise<void> {
router.post('/update/push.json', (req, res) => { router.post('/update/push.json', (req, res) => {
const token = req.header('X-Gitlab-Token'); const token = req.header('X-Gitlab-Token');
if (!token || token !== config.get<string>('gitlab_webhook_token')) throw new ForbiddenHttpError('Invalid token', req.url); if (!token || token !== config.get<string>('gitlab_webhook_token'))
throw new ForbiddenHttpError('Invalid token', req.url);
this.update(req.body).catch(Logger.error); this.update(req.body).catch(Logger.error);
@ -23,20 +29,20 @@ export default class AutoUpdateComponent extends ApplicationComponent {
}); });
} }
private async update(params: any) { private async update(params: { [p: string]: unknown }) {
Logger.info('Update params:', params); Logger.info('Update params:', params);
try { try {
Logger.info('Starting auto update...'); Logger.info('Starting auto update...');
// Fetch // Fetch
await this.runCommand(`git pull`); await AutoUpdateComponent.runCommand(`git pull`);
// Install new dependencies // Install new dependencies
await this.runCommand(`yarn install --production=false`); await AutoUpdateComponent.runCommand(`yarn install --production=false`);
// Process assets // Process assets
await this.runCommand(`yarn dist`); await AutoUpdateComponent.runCommand(`yarn dist`);
// Stop app // Stop app
await this.getApp().stop(); await this.getApp().stop();
@ -46,9 +52,4 @@ export default class AutoUpdateComponent extends ApplicationComponent {
Logger.error(e, 'An error occurred while running the auto update.'); Logger.error(e, 'An error occurred while running the auto update.');
} }
} }
private async runCommand(command: string): Promise<void> {
Logger.info(`> ${command}`);
Logger.info(child_process.execSync(command).toString());
}
} }

View File

@ -7,14 +7,14 @@ import {AuthMiddleware} from "../auth/AuthComponent";
export default class CsrfProtectionComponent extends ApplicationComponent { export default class CsrfProtectionComponent extends ApplicationComponent {
private static readonly excluders: ((req: Request) => boolean)[] = []; private static readonly excluders: ((req: Request) => boolean)[] = [];
public static getCSRFToken(session: Express.Session): string { public static getCsrfToken(session: Express.Session): string {
if (typeof session.csrf !== 'string') { if (typeof session.csrf !== 'string') {
session.csrf = crypto.randomBytes(64).toString('base64'); session.csrf = crypto.randomBytes(64).toString('base64');
} }
return session.csrf; return session.csrf;
} }
public static addExcluder(excluder: (req: Request) => boolean) { public static addExcluder(excluder: (req: Request) => boolean): void {
this.excluders.push(excluder); this.excluders.push(excluder);
} }
@ -24,22 +24,19 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
if (excluder(req)) return next(); if (excluder(req)) return next();
} }
if (!req.session) { const session = req.getSession();
throw new Error('Session is unavailable.'); res.locals.getCsrfToken = () => {
} return CsrfProtectionComponent.getCsrfToken(session);
res.locals.getCSRFToken = () => {
return CsrfProtectionComponent.getCSRFToken(req.session!);
}; };
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) { if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
try { try {
if (!await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req)) { if (!await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req)) {
if (req.session.csrf === undefined) { if (session.csrf === undefined) {
return next(new InvalidCsrfTokenError(req.baseUrl, `You weren't assigned any CSRF token.`)); return next(new InvalidCsrfTokenError(req.baseUrl, `You weren't assigned any CSRF token.`));
} else if (req.body.csrf === undefined) { } else if (req.body.csrf === undefined) {
return next(new InvalidCsrfTokenError(req.baseUrl, `You didn't provide any CSRF token.`)); return next(new InvalidCsrfTokenError(req.baseUrl, `You didn't provide any CSRF token.`));
} else if (req.session.csrf !== req.body.csrf) { } else if (session.csrf !== req.body.csrf) {
return next(new InvalidCsrfTokenError(req.baseUrl, `Tokens don't match.`)); return next(new InvalidCsrfTokenError(req.baseUrl, `Tokens don't match.`));
} }
} }
@ -53,20 +50,20 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
} }
class InvalidCsrfTokenError extends BadRequestError { class InvalidCsrfTokenError extends BadRequestError {
constructor(url: string, details: string, cause?: Error) { public constructor(url: string, details: string, cause?: Error) {
super( super(
`Invalid CSRF token`, `Invalid CSRF token`,
`${details} We can't process this request. Please try again.`, `${details} We can't process this request. Please try again.`,
url, url,
cause cause,
); );
} }
get name(): string { public get name(): string {
return 'Invalid CSRF Token'; return 'Invalid CSRF Token';
} }
get errorCode(): number { public get errorCode(): number {
return 401; return 401;
} }
} }

View File

@ -31,7 +31,7 @@ export default class ExpressAppComponent extends ApplicationComponent {
public async init(router: Router): Promise<void> { public async init(router: Router): Promise<void> {
router.use(express.json({ router.use(express.json({
type: req => req.headers['content-type']?.match(/^application\/(.+\+)?json$/) type: req => req.headers['content-type']?.match(/^application\/(.+\+)?json$/),
})); }));
router.use(express.urlencoded({ router.use(express.urlencoded({
extended: true, extended: true,
@ -42,7 +42,7 @@ export default class ExpressAppComponent extends ApplicationComponent {
router.use((req, res, next) => { router.use((req, res, next) => {
req.middlewares = []; req.middlewares = [];
req.as = <M extends Middleware>(type: Type<M>) => { req.as = <M extends Middleware>(type: Type<M>): M => {
const middleware = req.middlewares.find(m => m.constructor === type); const middleware = req.middlewares.find(m => m.constructor === type);
if (!middleware) throw new Error('Middleware ' + type.name + ' not present in this request.'); if (!middleware) throw new Error('Middleware ' + type.name + ' not present in this request.');
return middleware as M; return middleware as M;
@ -52,8 +52,9 @@ export default class ExpressAppComponent extends ApplicationComponent {
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
if (this.server) { const server = this.server;
await this.close('Webserver', this.server, this.server.close); if (server) {
await this.close('Webserver', callback => server.close(callback));
} }
} }
@ -63,6 +64,7 @@ export default class ExpressAppComponent extends ApplicationComponent {
} }
public getExpressApp(): Express { public getExpressApp(): Express {
return this.expressApp!; if (!this.expressApp) throw new Error('Express app not initialized.');
return this.expressApp;
} }
} }

View File

@ -4,11 +4,7 @@ import {Router} from "express";
export default class FormHelperComponent extends ApplicationComponent { export default class FormHelperComponent extends ApplicationComponent {
public async init(router: Router): Promise<void> { public async init(router: Router): Promise<void> {
router.use((req, res, next) => { router.use((req, res, next) => {
if (!req.session) { let _validation: unknown | null;
throw new Error('Session is unavailable.');
}
let _validation: any = null;
res.locals.validation = () => { res.locals.validation = () => {
if (!_validation) { if (!_validation) {
const v = req.flash('validation'); const v = req.flash('validation');
@ -16,9 +12,9 @@ export default class FormHelperComponent extends ApplicationComponent {
} }
return _validation; return _validation;
} };
let _previousFormData: any = null; let _previousFormData: unknown | null = null;
res.locals.previousFormData = () => { res.locals.previousFormData = () => {
if (!_previousFormData) { if (!_previousFormData) {
const v = req.flash('previousFormData'); const v = req.flash('previousFormData');

View File

@ -2,29 +2,24 @@ import ApplicationComponent from "../ApplicationComponent";
import onFinished from "on-finished"; import onFinished from "on-finished";
import Logger from "../Logger"; import Logger from "../Logger";
import {Request, Response, Router} from "express"; import {Request, Response, Router} from "express";
import {HttpError} from "../HttpError";
export default class LogRequestsComponent extends ApplicationComponent { export default class LogRequestsComponent extends ApplicationComponent {
private static fullRequests: boolean = false; private static fullRequests: boolean = false;
public static logFullHttpRequests() { public static logFullHttpRequests(): void {
this.fullRequests = true; this.fullRequests = true;
Logger.info('Http requests will be logged with more details.'); Logger.info('Http requests will be logged with more details.');
} }
public static logRequest(req: Request, res: Response, err: any = null, additionalStr: string = '', silent: boolean = false): string { public static logRequest(
if (!LogRequestsComponent.fullRequests) { req: Request,
let logStr = `${req.method} ${req.originalUrl} - ${res.statusCode}`; res: Response,
if (err) { err?: unknown,
if (silent) { additionalStr: string = '',
logStr += ` ${err.errorCode} ${err.name}`; silent: boolean = false,
return Logger.silentError(err, logStr); ): string {
} else { if (LogRequestsComponent.fullRequests) {
return Logger.error(err, logStr, additionalStr, err);
}
} else {
Logger.info(logStr);
}
} else {
const requestObj = JSON.stringify({ const requestObj = JSON.stringify({
ip: req.ip, ip: req.ip,
host: req.hostname, host: req.hostname,
@ -36,16 +31,37 @@ export default class LogRequestsComponent extends ApplicationComponent {
body: req.body, body: req.body,
files: req.files, files: req.files,
cookies: req.cookies, cookies: req.cookies,
sessionId: req.sessionID, sessionId: req.session?.id,
result: { result: {
code: res.statusCode code: res.statusCode,
} },
}, null, 4); }, null, 4);
if (err) { if (err) {
if (err instanceof Error) {
return Logger.error(err, requestObj, err); return Logger.error(err, requestObj, err);
} else {
return Logger.error(new Error(String(err)), requestObj);
}
} else { } else {
Logger.info(requestObj); Logger.info(requestObj);
} }
} else {
let logStr = `${req.method} ${req.originalUrl} - ${res.statusCode}`;
if (err) {
if (err instanceof Error) {
if (silent) {
if (err instanceof HttpError) logStr += ` ${err.errorCode}`;
logStr += ` ${err.name}`;
return Logger.silentError(err, logStr);
} else {
return Logger.error(err, logStr, additionalStr, err);
}
} else {
return Logger.error(new Error(String(err)), logStr);
}
} else {
Logger.info(logStr);
}
} }
return ''; return '';

View File

@ -15,7 +15,7 @@ export default class MailComponent extends ApplicationComponent {
} }
} }
public async start(app: Express): Promise<void> { public async start(_app: Express): Promise<void> {
await this.prepare('Mail connection', () => Mail.prepare()); await this.prepare('Mail connection', () => Mail.prepare());
} }

View File

@ -8,7 +8,7 @@ export default class MaintenanceComponent extends ApplicationComponent {
private readonly application: Application; private readonly application: Application;
private readonly canServe: () => boolean; private readonly canServe: () => boolean;
constructor(application: Application, canServe: () => boolean) { public constructor(application: Application, canServe: () => boolean) {
super(); super();
this.application = application; this.application = application;
this.canServe = canServe; this.canServe = canServe;

View File

@ -3,7 +3,7 @@ import {Express} from "express";
import MysqlConnectionManager from "../db/MysqlConnectionManager"; import MysqlConnectionManager from "../db/MysqlConnectionManager";
export default class MysqlComponent extends ApplicationComponent { export default class MysqlComponent extends ApplicationComponent {
public async start(app: Express): Promise<void> { public async start(_app: Express): Promise<void> {
await this.prepare('Mysql connection', () => MysqlConnectionManager.prepare()); await this.prepare('Mysql connection', () => MysqlConnectionManager.prepare());
} }

View File

@ -3,9 +3,7 @@ import config from "config";
import {Express, NextFunction, Request, Response, Router} from "express"; import {Express, NextFunction, Request, Response, Router} from "express";
import ApplicationComponent from "../ApplicationComponent"; import ApplicationComponent from "../ApplicationComponent";
import Controller from "../Controller"; import Controller from "../Controller";
import {ServerError} from "../HttpError";
import * as querystring from "querystring"; import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring";
import * as util from "util"; import * as util from "util";
import * as path from "path"; import * as path from "path";
import * as fs from "fs"; import * as fs from "fs";
@ -42,11 +40,7 @@ export default class NunjucksComponent extends ApplicationComponent {
noCache: !config.get('view.cache'), noCache: !config.get('view.cache'),
throwOnUndefined: true, throwOnUndefined: true,
}) })
.addGlobal('route', (route: string, params?: { [p: string]: string } | [], query?: ParsedUrlQueryInput, absolute?: boolean) => { .addGlobal('route', Controller.route)
const path = Controller.route(route, params, query, absolute);
if (path === null) throw new ServerError(`Route ${route} not found.`);
return path;
})
.addGlobal('app_version', this.getApp().getVersion()) .addGlobal('app_version', this.getApp().getVersion())
.addGlobal('core_version', coreVersion) .addGlobal('core_version', coreVersion)
.addGlobal('querystring', querystring) .addGlobal('querystring', querystring)
@ -62,7 +56,7 @@ export default class NunjucksComponent extends ApplicationComponent {
app.set('view engine', 'njk'); app.set('view engine', 'njk');
} }
public async init(router: Router): Promise<void> { public async init(_router: Router): Promise<void> {
this.use(NunjucksMiddleware); this.use(NunjucksMiddleware);
} }

View File

@ -11,7 +11,7 @@ export default class RedirectBackComponent extends ApplicationComponent {
public async handle(router: Router): Promise<void> { public async handle(router: Router): Promise<void> {
router.use((req, res, next) => { router.use((req, res, next) => {
res.redirectBack = (defaultUrl?: string) => { res.redirectBack = (defaultUrl?: string): void => {
const previousUrl = RedirectBackComponent.getPreviousURL(req, defaultUrl); const previousUrl = RedirectBackComponent.getPreviousURL(req, defaultUrl);
if (!previousUrl) throw new ServerError(`Couldn't redirect you back.`); if (!previousUrl) throw new ServerError(`Couldn't redirect you back.`);
res.redirect(previousUrl); res.redirect(previousUrl);
@ -22,12 +22,15 @@ export default class RedirectBackComponent extends ApplicationComponent {
}; };
onFinished(res, (err) => { onFinished(res, (err) => {
if (req.session) { const session = req.session;
const contentType = (res.getHeaders())['content-type']; if (session) {
if (!err && res.statusCode === 200 && (contentType && typeof contentType !== 'number' && contentType.indexOf('text/html') >= 0)) { const contentType = res.getHeader('content-type');
req.session!.previousUrl = req.originalUrl; if (!err && res.statusCode === 200 && (
Logger.debug('Prev url set to', req.session!.previousUrl); contentType && typeof contentType !== 'number' && contentType.indexOf('text/html') >= 0
req.session!.save((err) => { )) {
session.previousUrl = req.originalUrl;
Logger.debug('Prev url set to', session.previousUrl);
session.save((err) => {
if (err) { if (err) {
Logger.error(err, 'Error while saving session'); Logger.error(err, 'Error while saving session');
} }

View File

@ -13,11 +13,11 @@ export default class RedisComponent extends ApplicationComponent implements Cach
private redisClient?: RedisClient; private redisClient?: RedisClient;
private store?: Store; private store?: Store;
public async start(app: Express): Promise<void> { public async start(_app: Express): Promise<void> {
this.redisClient = redis.createClient(config.get('redis.port'), config.get('redis.host'), { this.redisClient = redis.createClient(config.get('redis.port'), config.get('redis.host'), {
password: config.has('redis.password') ? config.get<string>('redis.password') : undefined, password: config.has('redis.password') ? config.get<string>('redis.password') : undefined,
}); });
this.redisClient.on('error', (err: any) => { this.redisClient.on('error', (err: Error) => {
Logger.error(err, 'An error occurred with redis.'); Logger.error(err, 'An error occurred with redis.');
}); });
this.store = new RedisStore({ this.store = new RedisStore({
@ -27,8 +27,9 @@ export default class RedisComponent extends ApplicationComponent implements Cach
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
if (this.redisClient) { const redisClient = this.redisClient;
await this.close('Redis connection', this.redisClient, this.redisClient.quit); if (redisClient) {
await this.close('Redis connection', callback => redisClient.quit(callback));
} }
} }
@ -42,7 +43,7 @@ export default class RedisComponent extends ApplicationComponent implements Cach
} }
public async get(key: string, defaultValue?: string): Promise<string | null> { public async get(key: string, defaultValue?: string): Promise<string | null> {
return new Promise<string | null>((resolve, reject) => { return await new Promise<string | null>((resolve, reject) => {
if (!this.redisClient) { if (!this.redisClient) {
reject(`Redis store was not initialized.`); reject(`Redis store was not initialized.`);
return; return;
@ -53,7 +54,7 @@ export default class RedisComponent extends ApplicationComponent implements Cach
reject(err); reject(err);
return; return;
} }
resolve(val !== null ? val : (defaultValue !== undefined ? defaultValue : null)); resolve(val || defaultValue || null);
}); });
}); });
} }
@ -63,7 +64,7 @@ export default class RedisComponent extends ApplicationComponent implements Cach
} }
public async forget(key: string): Promise<void> { public async forget(key: string): Promise<void> {
return new Promise<void>((resolve, reject) => { return await new Promise<void>((resolve, reject) => {
if (!this.redisClient) { if (!this.redisClient) {
reject(`Redis store was not initialized.`); reject(`Redis store was not initialized.`);
return; return;
@ -81,7 +82,7 @@ export default class RedisComponent extends ApplicationComponent implements Cach
} }
public async remember(key: string, value: string, ttl: number): Promise<void> { public async remember(key: string, value: string, ttl: number): Promise<void> {
return new Promise<void>((resolve, reject) => { return await new Promise<void>((resolve, reject) => {
if (!this.redisClient) { if (!this.redisClient) {
reject(`Redis store was not initialized.`); reject(`Redis store was not initialized.`);
return; return;

View File

@ -7,21 +7,18 @@ export default class ServeStaticDirectoryComponent extends ApplicationComponent
private readonly root: string; private readonly root: string;
private readonly path?: PathParams; private readonly path?: PathParams;
constructor(root: string, routePath?: PathParams) { public constructor(root: string, routePath?: PathParams) {
super(); super();
this.root = path.join(__dirname, '../../../', root); this.root = path.join(__dirname, '../../../', root);
this.path = routePath; this.path = routePath;
} }
public async init(router: Router): Promise<void> { public async init(router: Router): Promise<void> {
if (typeof this.path !== 'undefined') { if (this.path) {
router.use(this.path, express.static(this.root, {maxAge: 1000 * 3600 * 72})); router.use(this.path, express.static(this.root, {maxAge: 1000 * 3600 * 72}));
} else { } else {
router.use(express.static(this.root, {maxAge: 1000 * 3600 * 72})); router.use(express.static(this.root, {maxAge: 1000 * 3600 * 72}));
} }
} }
public async stop(): Promise<void> {
}
} }

View File

@ -14,7 +14,6 @@ export default class SessionComponent extends ApplicationComponent {
this.storeComponent = storeComponent; this.storeComponent = storeComponent;
} }
public async checkSecuritySettings(): Promise<void> { public async checkSecuritySettings(): Promise<void> {
this.checkSecurityConfigField('session.secret'); this.checkSecurityConfigField('session.secret');
if (!config.get<boolean>('session.cookie.secure')) { if (!config.get<boolean>('session.cookie.secure')) {
@ -39,17 +38,18 @@ export default class SessionComponent extends ApplicationComponent {
router.use(flash()); router.use(flash());
router.use((req, res, next) => { router.use((req, res, next) => {
if (!req.session) { req.getSession = () => {
throw new Error('Session is unavailable.'); if (!req.session) throw new Error('Session not initialized.');
} return req.session;
};
res.locals.session = req.session; res.locals.session = req.getSession();
let _flash: any = {}; const _flash: FlashStorage = {};
res.locals.flash = (key?: string) => { res.locals.flash = (key?: string): FlashMessages | unknown[] => {
if (key !== undefined) { if (key !== undefined) {
if (_flash[key] === undefined) _flash[key] = req.flash(key) || null; if (_flash[key] === undefined) _flash[key] = req.flash(key);
return _flash[key]; return _flash[key] || [];
} }
if (_flash._messages === undefined) { if (_flash._messages === undefined) {
@ -66,3 +66,18 @@ export default class SessionComponent extends ApplicationComponent {
}); });
} }
} }
export type FlashMessages = {
[k: string]: unknown[] | undefined
};
export type DefaultFlashMessages = FlashMessages & {
info?: unknown[] | undefined;
success?: unknown[] | undefined;
warning?: unknown[] | undefined;
error?: unknown[] | undefined;
};
type FlashStorage = FlashMessages & {
_messages?: DefaultFlashMessages,
};

View File

@ -14,7 +14,7 @@ import NunjucksComponent from "./NunjucksComponent";
export default class WebSocketServerComponent extends ApplicationComponent { export default class WebSocketServerComponent extends ApplicationComponent {
private wss?: WebSocket.Server; private wss?: WebSocket.Server;
constructor( public constructor(
private readonly application: Application, private readonly application: Application,
private readonly expressAppComponent: ExpressAppComponent, private readonly expressAppComponent: ExpressAppComponent,
private readonly storeComponent: RedisComponent, private readonly storeComponent: RedisComponent,
@ -23,8 +23,8 @@ export default class WebSocketServerComponent extends ApplicationComponent {
super(); super();
} }
public async start(app: Express): Promise<void> { public async start(_app: Express): Promise<void> {
const listeners: { [p: string]: WebSocketListener<any> } = this.application.getWebSocketListeners(); const listeners: { [p: string]: WebSocketListener<Application> } = this.application.getWebSocketListeners();
this.wss = new WebSocketServer({ this.wss = new WebSocketServer({
server: this.expressAppComponent.getServer(), server: this.expressAppComponent.getServer(),
}, () => { }, () => {
@ -78,8 +78,9 @@ export default class WebSocketServerComponent extends ApplicationComponent {
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
if (this.wss) { const wss = this.wss;
await this.close('WebSocket server', this.wss, this.wss.close); if (wss) {
await this.close('WebSocket server', callback => wss.close(callback));
} }
} }
} }

View File

@ -1,5 +1,6 @@
import {Connection} from "mysql"; import {Connection} from "mysql";
import MysqlConnectionManager from "./MysqlConnectionManager"; import MysqlConnectionManager from "./MysqlConnectionManager";
import {Type} from "../Utils";
export default abstract class Migration { export default abstract class Migration {
public readonly version: number; public readonly version: number;
@ -16,9 +17,13 @@ export default abstract class Migration {
public abstract async rollback(connection: Connection): Promise<void>; public abstract async rollback(connection: Connection): Promise<void>;
public abstract registerModels(): void; public registerModels?(): void;
protected async query(queryString: string, connection: Connection): Promise<void> { protected async query(queryString: string, connection: Connection): Promise<void> {
await MysqlConnectionManager.query(queryString, undefined, connection); await MysqlConnectionManager.query(queryString, undefined, connection);
} }
} }
export interface MigrationType<M extends Migration> extends Type<M> {
new(version: number): M;
}

View File

@ -1,69 +1,75 @@
import MysqlConnectionManager, {query} from "./MysqlConnectionManager"; import MysqlConnectionManager, {isQueryVariable, query, QueryVariable} from "./MysqlConnectionManager";
import Validator from "./Validator"; import Validator from "./Validator";
import {Connection} from "mysql"; import {Connection} from "mysql";
import ModelComponent from "./ModelComponent"; import ModelComponent from "./ModelComponent";
import {Type} from "../Utils"; import {Type} from "../Utils";
import ModelFactory from "./ModelFactory"; import ModelFactory, {PrimaryKeyValue} from "./ModelFactory";
import ModelRelation from "./ModelRelation"; import ModelRelation from "./ModelRelation";
import ModelQuery, {ModelQueryResult, SelectFields} from "./ModelQuery"; import ModelQuery, {ModelFieldData, ModelQueryResult, SelectFields} from "./ModelQuery";
import {Request} from "express"; import {Request} from "express";
import Extendable from "../Extendable"; import Extendable from "../Extendable";
export default abstract class Model implements Extendable<ModelComponent<Model>> { export default abstract class Model implements Extendable<ModelComponent<Model>> {
public static get table(): string { public static get table(): string {
return this.name const single = this.name
.replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase()) .replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase())
.replace(/^_/, '') .replace(/^_/, '');
+ 's'; return single + 's';
} }
public static getPrimaryKeyFields(): string[] { public static getPrimaryKeyFields(): string[] {
return ['id']; return ['id'];
} }
public static create<T extends Model>(this: ModelType<T>, data: any): T { public static create<M extends Model>(this: ModelType<M>, data: Pick<M, keyof M>): M {
return ModelFactory.get(this).create(data, true); return ModelFactory.get(this).create(data, true);
} }
public static select<T extends Model>(this: ModelType<T>, ...fields: SelectFields): ModelQuery<T> { public static select<M extends Model>(this: ModelType<M>, ...fields: SelectFields): ModelQuery<M> {
return ModelFactory.get(this).select(...fields); return ModelFactory.get(this).select(...fields);
} }
public static update<T extends Model>(this: ModelType<T>, data: { [key: string]: any }): ModelQuery<T> { public static update<M extends Model>(this: ModelType<M>, data: Pick<M, keyof M>): ModelQuery<M> {
return ModelFactory.get(this).update(data); return ModelFactory.get(this).update(data);
} }
public static delete<T extends Model>(this: ModelType<T>): ModelQuery<T> { public static delete<M extends Model>(this: ModelType<M>): ModelQuery<M> {
return ModelFactory.get(this).delete(); return ModelFactory.get(this).delete();
} }
public static async getById<T extends Model>(this: ModelType<T>, ...id: any): Promise<T | null> { public static async getById<M extends Model>(this: ModelType<M>, ...id: PrimaryKeyValue[]): Promise<M | null> {
return ModelFactory.get(this).getById(...id); return await ModelFactory.get(this).getById(...id);
} }
public static async paginate<T extends Model>(this: ModelType<T>, request: Request, perPage: number = 20, query?: ModelQuery<T>): Promise<ModelQueryResult<T>> { public static async paginate<M extends Model>(
return ModelFactory.get(this).paginate(request, perPage, query); this: ModelType<M>,
request: Request,
perPage: number = 20,
query?: ModelQuery<M>,
): Promise<ModelQueryResult<M>> {
return await ModelFactory.get(this).paginate(request, perPage, query);
} }
protected readonly _factory: ModelFactory<Model>; protected readonly _factory: ModelFactory<Model>;
private readonly _components: ModelComponent<this>[] = []; private readonly _components: ModelComponent<this>[] = [];
private readonly _validators: { [key: string]: Validator<any> | undefined } = {}; private readonly _validators: { [K in keyof this]?: Validator<this[K]> | undefined } = {};
private _exists: boolean; private _exists: boolean;
[key: string]: any; [key: string]: ModelFieldData;
public constructor(factory: ModelFactory<Model>, isNew: boolean) { public constructor(factory: ModelFactory<never>, isNew: boolean) {
if (!factory || !(factory instanceof ModelFactory)) throw new Error('Cannot instantiate model directly.'); if (!(factory instanceof ModelFactory)) throw new Error('Cannot instantiate model directly.');
this._factory = factory; this._factory = factory;
this.init(); this.init?.();
this._exists = !isNew; this._exists = !isNew;
} }
protected abstract init(): void; protected init?(): void;
protected setValidation<T>(propertyName: keyof this): Validator<T> { protected setValidation<K extends keyof this>(propertyName: K): Validator<this[K]> {
const validator = new Validator<T>(); const validator = new Validator<this[K]>();
this._validators[propertyName as string] = validator; this._validators[propertyName] = validator;
return validator; return validator;
} }
@ -92,10 +98,10 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
return null; return null;
} }
public updateWithData(data: any) { public updateWithData(data: Pick<this, keyof this> | Record<string, unknown>): void {
for (const property of this._properties) { for (const property of this._properties) {
if (data[property] !== undefined) { if (data[property] !== undefined) {
this[property] = data[property]; this[property] = data[property] as this[keyof this & string];
} }
} }
} }
@ -118,22 +124,24 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
const needs_full_update = connection ? const needs_full_update = connection ?
await this.saveTransaction(connection) : await this.saveTransaction(connection) :
await MysqlConnectionManager.wrapTransaction(async connection => this.saveTransaction(connection)); await MysqlConnectionManager.wrapTransaction(async connection => await this.saveTransaction(connection));
const callback = async () => { const callback = async () => {
if (needs_full_update) { if (needs_full_update) {
const result = await this._factory.select() const query = this._factory.select();
.where('id', this.id!) for (const field of this._factory.getPrimaryKeyFields()) {
.limit(1) query.where(field, this[field]);
.execute(connection); }
query.limit(1);
const result = await query.execute(connection);
this.updateWithData(result.results[0]); this.updateWithData(result.results[0]);
} }
await this.afterSave?.(); await this.afterSave?.();
}; };
if (connection) { if (postHook) {
postHook!(callback); postHook(callback);
} else { } else {
await callback(); await callback();
} }
@ -142,19 +150,19 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
private async saveTransaction(connection: Connection): Promise<boolean> { private async saveTransaction(connection: Connection): Promise<boolean> {
// Before save // Before save
await this.beforeSave?.(connection); await this.beforeSave?.(connection);
if (!this.exists() && this.hasOwnProperty('created_at')) { if (!this.exists() && this.hasProperty('created_at')) {
this.created_at = new Date(); this.created_at = new Date();
} }
if (this.exists() && this.hasOwnProperty('updated_at')) { if (this.exists() && this.hasProperty('updated_at')) {
this.updated_at = new Date(); this.updated_at = new Date();
} }
const properties = []; const properties = [];
const values = []; const values: QueryVariable[] = [];
let needsFullUpdate = false; let needsFullUpdate = false;
if (this.exists()) { if (this.exists()) {
const data: any = {}; const data: { [K in keyof this]?: this[K] } = {};
for (const property of this._properties) { for (const property of this._properties) {
const value = this[property]; const value = this[property];
@ -170,21 +178,24 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
} else { } else {
const props_holders = []; const props_holders = [];
for (const property of this._properties) { for (const property of this._properties) {
const value = this[property]; let value: ModelFieldData = this[property];
if (value === undefined) { if (value === undefined) {
needsFullUpdate = true; needsFullUpdate = true;
} else { } else {
if (!isQueryVariable(value)) {
value = value.toString();
}
properties.push(property); properties.push(property);
props_holders.push('?'); props_holders.push('?');
values.push(value); values.push(value as QueryVariable);
} }
} }
const fieldNames = properties.map(f => `\`${f}\``).join(', '); const fieldNames = properties.map(f => `\`${f}\``).join(', ');
const result = await query(`INSERT INTO ${this.table} (${fieldNames}) VALUES(${props_holders.join(', ')})`, values, connection); const result = await query(`INSERT INTO ${this.table} (${fieldNames}) VALUES(${props_holders.join(', ')})`, values, connection);
if (this.hasOwnProperty('id')) this.id = result.other.insertId; if (this.hasProperty('id')) this.id = Number(result.other?.insertId);
this._exists = true; this._exists = true;
} }
@ -192,7 +203,7 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
} }
public async delete(): Promise<void> { public async delete(): Promise<void> {
if (!(await this.exists())) throw new Error('This model instance doesn\'t exist in DB.'); if (!await this.exists()) throw new Error('This model instance doesn\'t exist in DB.');
const query = this._factory.delete(); const query = this._factory.delete();
for (const indexField of this._factory.getPrimaryKeyFields()) { for (const indexField of this._factory.getPrimaryKeyFields()) {
@ -204,7 +215,7 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
public async validate(onlyFormat: boolean = false, connection?: Connection): Promise<void[]> { public async validate(onlyFormat: boolean = false, connection?: Connection): Promise<void[]> {
return await Promise.all(this._properties.map( return await Promise.all(this._properties.map(
prop => this._validators[prop]?.execute(prop, this[prop], onlyFormat, connection) prop => this._validators[prop]?.execute(prop, this[prop], onlyFormat, connection),
)); ));
} }
@ -223,19 +234,30 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
return this._factory.table; return this._factory.table;
} }
private get _properties(): string[] { private get _properties(): (keyof this & string)[] {
return Object.getOwnPropertyNames(this).filter(p => { return Object.getOwnPropertyNames(this).filter(p => {
return !p.startsWith('_') && return !p.startsWith('_') &&
typeof this[p] !== 'function' && typeof this[p] !== 'function' &&
!(this[p] instanceof ModelRelation); !(this[p] instanceof ModelRelation);
}); });
} }
private hasProperty(key: string | number | symbol): key is keyof this {
return typeof key === 'string' && this._properties.indexOf(key) >= 0;
}
public getOrFail<K extends keyof this & string>(k: K): NonNullable<this[K]> {
if (!this[k]) throw new Error(k + ' not initialized.');
return this[k] as NonNullable<this[K]>;
}
} }
export interface ModelType<T extends Model> extends Type<T> { export interface ModelType<M extends Model> extends Type<M> {
table: string; table: string;
new(factory: ModelFactory<any>, isNew: boolean): T; new(factory: ModelFactory<never>, isNew: boolean): M;
getPrimaryKeyFields(): string[]; getPrimaryKeyFields(): (keyof M & string)[];
select<M extends Model>(this: ModelType<M>, ...fields: SelectFields): ModelQuery<M>;
} }

View File

@ -1,42 +1,43 @@
import Model from "./Model"; import Model from "./Model";
import Validator from "./Validator"; import Validator from "./Validator";
import {getMethods} from "../Utils"; import {getMethods} from "../Utils";
import {ModelFieldData} from "./ModelQuery";
export default abstract class ModelComponent<T extends Model> { export default abstract class ModelComponent<M extends Model> {
protected readonly _model: T; protected readonly _model: M;
private readonly _validators: { [key: string]: Validator<any> } = {}; private readonly _validators: { [K in keyof this]?: Validator<this[K]> } = {};
[key: string]: any; [key: string]: ModelFieldData;
public constructor(model: T) { public constructor(model: M) {
this._model = model; this._model = model;
} }
public applyToModel(): void { public applyToModel(): void {
this.init?.(); this.init?.();
const model = this._model as Model;
for (const property of this._properties) { for (const property of this._properties) {
if (!property.startsWith('_')) { if (!property.startsWith('_')) {
(this._model as Model)[property] = this[property]; model[property] = this[property];
} }
} }
for (const method of getMethods(this)) { for (const method of getMethods(this)) {
// @ts-ignore
if (!method.startsWith('_') && if (!method.startsWith('_') &&
['init', 'setValidation'].indexOf(method) < 0 && ['init', 'setValidation'].indexOf(method) < 0 &&
this._model[method] === undefined) { model[method] === undefined) {
// @ts-ignore model[method] = this[method];
this._model[method] = this[method];
} }
} }
} }
protected init?(): void; protected init?(): void;
protected setValidation<V>(propertyName: keyof this): Validator<V> { protected setValidation<K extends keyof this>(propertyName: K): Validator<this[K]> {
const validator = new Validator<V>(); const validator = new Validator<this[K]>();
this._validators[propertyName as string] = validator; this._validators[propertyName] = validator;
return validator; return validator;
} }

View File

@ -3,33 +3,37 @@ import Model, {ModelType} from "./Model";
import ModelQuery, {ModelQueryResult, SelectFields} from "./ModelQuery"; import ModelQuery, {ModelQueryResult, SelectFields} from "./ModelQuery";
import {Request} from "express"; import {Request} from "express";
export default class ModelFactory<T extends Model> { export default class ModelFactory<M extends Model> {
private static readonly factories: { [modelType: string]: ModelFactory<any> } = {}; private static readonly factories: { [modelType: string]: ModelFactory<Model> | undefined } = {};
public static register<M extends Model>(modelType: ModelType<M>) { public static register<M extends Model>(modelType: ModelType<M>): void {
if (this.factories[modelType.name]) throw new Error(`Factory for type ${modelType.name} already defined.`); if (this.factories[modelType.name]) throw new Error(`Factory for type ${modelType.name} already defined.`);
this.factories[modelType.name] = new ModelFactory<M>(modelType); this.factories[modelType.name] = new ModelFactory<M>(modelType) as unknown as ModelFactory<Model>;
} }
public static get<M extends Model>(modelType: ModelType<M>): ModelFactory<M> { public static get<M extends Model>(modelType: ModelType<M>): ModelFactory<M> {
const factory = this.factories[modelType.name]; const factory = this.factories[modelType.name];
if (!factory) throw new Error(`No factory registered for ${modelType.name}.`); if (!factory) throw new Error(`No factory registered for ${modelType.name}.`);
return factory; return factory as unknown as ModelFactory<M>;
} }
private readonly modelType: ModelType<T>; public static has<M extends Model>(modelType: ModelType<M>): boolean {
private readonly components: ModelComponentFactory<T>[] = []; return !!this.factories[modelType.name];
}
protected constructor(modelType: ModelType<T>) { private readonly modelType: ModelType<M>;
private readonly components: ModelComponentFactory<M>[] = [];
protected constructor(modelType: ModelType<M>) {
this.modelType = modelType; this.modelType = modelType;
} }
public addComponent(modelComponentFactory: ModelComponentFactory<T>) { public addComponent(modelComponentFactory: ModelComponentFactory<M>): void {
this.components.push(modelComponentFactory); this.components.push(modelComponentFactory);
} }
public create(data: any, isNewModel: boolean): T { public create(data: Pick<M, keyof M>, isNewModel: boolean): M {
const model = new this.modelType(this, isNewModel); const model = new this.modelType(this as unknown as ModelFactory<never>, isNewModel);
for (const component of this.components) { for (const component of this.components) {
model.addComponent(new component(model)); model.addComponent(new component(model));
} }
@ -41,41 +45,41 @@ export default class ModelFactory<T extends Model> {
return this.modelType.table; return this.modelType.table;
} }
public select(...fields: SelectFields): ModelQuery<T> { public select(...fields: SelectFields): ModelQuery<M> {
return ModelQuery.select(this, ...fields); return ModelQuery.select(this, ...fields);
} }
public update(data: { [key: string]: any }): ModelQuery<T> { public update(data: Pick<M, keyof M>): ModelQuery<M> {
return ModelQuery.update(this, data); return ModelQuery.update(this, data);
} }
public delete(): ModelQuery<T> { public delete(): ModelQuery<M> {
return ModelQuery.delete(this); return ModelQuery.delete(this);
} }
public getPrimaryKeyFields(): string[] { public getPrimaryKeyFields(): (keyof M & string)[] {
return this.modelType.getPrimaryKeyFields(); return this.modelType.getPrimaryKeyFields();
} }
public getPrimaryKey(modelData: any): any[] { public getPrimaryKey(modelData: Pick<M, keyof M>): Pick<M, keyof M>[keyof M & string][] {
return this.getPrimaryKeyFields().map(f => modelData[f]); return this.getPrimaryKeyFields().map(f => modelData[f]);
} }
public getPrimaryKeyString(modelData: any): string { public getPrimaryKeyString(modelData: Pick<M, keyof M>): string {
return this.getPrimaryKey(modelData).join(','); return this.getPrimaryKey(modelData).join(',');
} }
public async getById(...id: any): Promise<T | null> { public async getById(...id: PrimaryKeyValue[]): Promise<M | null> {
let query = this.select(); let query = this.select();
const primaryKeyFields = this.getPrimaryKeyFields(); const primaryKeyFields = this.getPrimaryKeyFields();
for (let i = 0; i < primaryKeyFields.length; i++) { for (let i = 0; i < primaryKeyFields.length; i++) {
query = query.where(primaryKeyFields[i], id[i]); query = query.where(primaryKeyFields[i], id[i]);
} }
return query.first(); return await query.first();
} }
public async paginate(request: Request, perPage: number = 20, query?: ModelQuery<T>): Promise<ModelQueryResult<T>> { public async paginate(request: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> {
let page = request.params.page ? parseInt(request.params.page) : 1; const page = request.params.page ? parseInt(request.params.page) : 1;
if (!query) query = this.select(); if (!query) query = this.select();
if (request.params.sortBy) { if (request.params.sortBy) {
const dir = request.params.sortDirection; const dir = request.params.sortDirection;
@ -87,4 +91,6 @@ export default class ModelFactory<T extends Model> {
} }
} }
export type ModelComponentFactory<T extends Model> = new (model: T) => ModelComponent<T>; export type ModelComponentFactory<M extends Model> = new (model: M) => ModelComponent<M>;
export type PrimaryKeyValue = string | number | boolean | null | undefined;

View File

@ -1,4 +1,4 @@
import {query, QueryResult} from "./MysqlConnectionManager"; import {isQueryVariable, query, QueryResult, QueryVariable} from "./MysqlConnectionManager";
import {Connection} from "mysql"; import {Connection} from "mysql";
import Model from "./Model"; import Model from "./Model";
import Pagination from "../Pagination"; import Pagination from "../Pagination";
@ -12,15 +12,11 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return new ModelQuery(QueryType.SELECT, factory, fields.length > 0 ? fields : ['*']); return new ModelQuery(QueryType.SELECT, factory, fields.length > 0 ? fields : ['*']);
} }
public static update<M extends Model>(factory: ModelFactory<M>, data: { public static update<M extends Model>(factory: ModelFactory<M>, data: { [K in keyof M]?: M[K] }): ModelQuery<M> {
[key: string]: any
}): ModelQuery<M> {
const fields = []; const fields = [];
for (let key in data) { for (const key of Object.keys(data)) {
if (data.hasOwnProperty(key)) {
fields.push(new UpdateFieldValue(inputToFieldOrValue(key, factory.table), data[key], false)); fields.push(new UpdateFieldValue(inputToFieldOrValue(key, factory.table), data[key], false));
} }
}
return new ModelQuery(QueryType.UPDATE, factory, fields); return new ModelQuery(QueryType.UPDATE, factory, fields);
} }
@ -41,7 +37,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
private _sortBy?: string; private _sortBy?: string;
private _sortDirection?: 'ASC' | 'DESC'; private _sortDirection?: 'ASC' | 'DESC';
private readonly relations: string[] = []; private readonly relations: string[] = [];
private readonly subRelations: { [relation: string]: string[] } = {}; private readonly subRelations: { [relation: string]: string[] | undefined } = {};
private _pivot?: string[]; private _pivot?: string[];
private _union?: ModelQueryUnion; private _union?: ModelQueryUnion;
private _recursiveRelation?: RelationDatabaseProperties; private _recursiveRelation?: RelationDatabaseProperties;
@ -60,33 +56,57 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return this; return this;
} }
public on(field1: string, field2: string, test: WhereTest = WhereTest.EQ, operator: WhereOperator = WhereOperator.AND): this { public on(
this._leftJoinOn.push(new WhereFieldValue(inputToFieldOrValue(field1), inputToFieldOrValue(field2), true, test, operator)); field1: string,
field2: string,
test: WhereTest = WhereTest.EQ,
operator: WhereOperator = WhereOperator.AND,
): this {
this._leftJoinOn.push(new WhereFieldValue(
inputToFieldOrValue(field1), inputToFieldOrValue(field2), true, test, operator,
));
return this; return this;
} }
public where(field: string, value: string | Date | ModelQuery<any> | any, test: WhereTest = WhereTest.EQ, operator: WhereOperator = WhereOperator.AND): this { public where(
field: string,
value: ModelFieldData,
test: WhereTest = WhereTest.EQ,
operator: WhereOperator = WhereOperator.AND,
): this {
this._where.push(new WhereFieldValue(field, value, false, test, operator)); this._where.push(new WhereFieldValue(field, value, false, test, operator));
return this; return this;
} }
public groupWhere(setter: (query: WhereFieldConsumer<M>) => void, operator: WhereOperator = WhereOperator.AND): this { public groupWhere(
setter: (query: WhereFieldConsumer<M>) => void,
operator: WhereOperator = WhereOperator.AND,
): this {
this._where.push(new WhereFieldValueGroup(this.collectWheres(setter), operator)); this._where.push(new WhereFieldValueGroup(this.collectWheres(setter), operator));
return this; return this;
} }
private collectWheres(setter: (query: WhereFieldConsumer<M>) => void): (WhereFieldValue | WhereFieldValueGroup)[] { private collectWheres(setter: (query: WhereFieldConsumer<M>) => void): (WhereFieldValue | WhereFieldValueGroup)[] {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const query = this; const query = this;
const wheres: (WhereFieldValue | WhereFieldValueGroup)[] = []; const wheres: (WhereFieldValue | WhereFieldValueGroup)[] = [];
setter({ setter({
where(field: string, value: string | Date | ModelQuery<any> | any, test: WhereTest = WhereTest.EQ, operator: WhereOperator = WhereOperator.AND) { where(
field: string,
value: ModelFieldData,
test: WhereTest = WhereTest.EQ,
operator: WhereOperator = WhereOperator.AND,
) {
wheres.push(new WhereFieldValue(field, value, false, test, operator)); wheres.push(new WhereFieldValue(field, value, false, test, operator));
return this; return this;
}, },
groupWhere(setter: (query: WhereFieldConsumer<M>) => void, operator: WhereOperator = WhereOperator.AND) { groupWhere(
wheres.push(new WhereFieldValueGroup(query.collectWheres(setter), operator)) setter: (query: WhereFieldConsumer<M>) => void,
operator: WhereOperator = WhereOperator.AND,
) {
wheres.push(new WhereFieldValueGroup(query.collectWheres(setter), operator));
return this; return this;
} },
}); });
return wheres; return wheres;
} }
@ -113,7 +133,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
if (this.relations.indexOf(parts[0]) < 0) this.relations.push(parts[0]); if (this.relations.indexOf(parts[0]) < 0) this.relations.push(parts[0]);
if (parts.length > 1) { if (parts.length > 1) {
if (!this.subRelations[parts[0]]) this.subRelations[parts[0]] = []; if (!this.subRelations[parts[0]]) this.subRelations[parts[0]] = [];
this.subRelations[parts[0]].push(parts.slice(1).join('.')); this.subRelations[parts[0]]?.push(parts.slice(1).join('.'));
} }
}); });
return this; return this;
@ -124,7 +144,13 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return this; return this;
} }
public union(query: ModelQuery<any>, sortBy: string, direction: SortDirection = 'ASC', raw: boolean = false, limit?: number, offset?: number): this { public union(
query: ModelQuery<Model>,
sortBy: string, direction: SortDirection = 'ASC',
raw: boolean = false,
limit?: number,
offset?: number,
): this {
if (this.type !== QueryType.SELECT) throw new Error('Union queries are only implemented with SELECT.'); if (this.type !== QueryType.SELECT) throw new Error('Union queries are only implemented with SELECT.');
this._union = { this._union = {
@ -150,7 +176,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
if (this._pivot) this.fields.push(...this._pivot); if (this._pivot) this.fields.push(...this._pivot);
// Prevent wildcard and fields from conflicting // Prevent wildcard and fields from conflicting
let fields = this.fields.map(f => { const fields = this.fields.map(f => {
const field = f.toString(); const field = f.toString();
if (field.startsWith('(')) return f; // Skip sub-queries if (field.startsWith('(')) return f; // Skip sub-queries
return inputToFieldOrValue(field, this.table); return inputToFieldOrValue(field, this.table);
@ -184,7 +210,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
let orderBy = ''; let orderBy = '';
if (typeof this._sortBy === 'string') { if (typeof this._sortBy === 'string') {
orderBy = ` ORDER BY ${this._sortBy} ${this._sortDirection!}`; orderBy = ` ORDER BY ${this._sortBy} ${this._sortDirection}`;
} }
const table = `\`${this.table}\``; const table = `\`${this.table}\``;
@ -193,7 +219,9 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
case QueryType.SELECT: case QueryType.SELECT:
if (this._recursiveRelation) { if (this._recursiveRelation) {
const cteFields = fields.replace(RegExp(`${table}`, 'g'), 'o'); const cteFields = fields.replace(RegExp(`${table}`, 'g'), 'o');
const idKey = this._reverseRecursiveRelation ? this._recursiveRelation.foreignKey : this._recursiveRelation.localKey; const idKey = this._reverseRecursiveRelation ?
this._recursiveRelation.foreignKey :
this._recursiveRelation.localKey;
const sortOrder = this._reverseRecursiveRelation ? 'DESC' : 'ASC'; const sortOrder = this._reverseRecursiveRelation ? 'DESC' : 'ASC';
query = `WITH RECURSIVE cte AS (` query = `WITH RECURSIVE cte AS (`
@ -228,10 +256,10 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return this.toString(true); return this.toString(true);
} }
public get variables(): any[] { public get variables(): QueryVariable[] {
const variables: any[] = []; const variables: QueryVariable[] = [];
this.fields?.filter(v => v instanceof FieldValue) this.fields.filter(v => v instanceof FieldValue)
.flatMap(v => (<FieldValue>v).variables) .flatMap(v => (v as FieldValue).variables)
.forEach(v => variables.push(v)); .forEach(v => variables.push(v));
this._where.flatMap(v => this.getVariables(v)) this._where.flatMap(v => this.getVariables(v))
.forEach(v => variables.push(v)); .forEach(v => variables.push(v));
@ -239,7 +267,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return variables; return variables;
} }
private getVariables(where: WhereFieldValue | WhereFieldValueGroup): any[] { private getVariables(where: WhereFieldValue | WhereFieldValueGroup): QueryVariable[] {
return where instanceof WhereFieldValueGroup ? return where instanceof WhereFieldValueGroup ?
where.fields.flatMap(v => this.getVariables(v)) : where.fields.flatMap(v => this.getVariables(v)) :
where.variables; where.variables;
@ -257,33 +285,34 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
if (this._pivot) models.pivot = []; if (this._pivot) models.pivot = [];
// Eager loading init // Eager loading init
const relationMap: { [p: string]: ModelRelation<any, any, any>[] } = {}; const relationMap: { [p: string]: ModelRelation<Model, Model, Model | Model[] | null>[] } = {};
for (const relation of this.relations) { for (const relation of this.relations) {
relationMap[relation] = []; relationMap[relation] = [];
} }
for (const result of queryResult.results) { for (const result of queryResult.results) {
const modelData: any = {}; const modelData: Record<string, unknown> = {};
for (const field of Object.keys(result)) { for (const field of Object.keys(result)) {
modelData[field.split('.')[1] || field] = result[field]; modelData[field.split('.')[1] || field] = result[field];
} }
const model = this.factory.create(modelData, false); const model = this.factory.create(modelData as Pick<M, keyof M>, false);
models.push(model); models.push(model);
models.originalData.push(modelData); models.originalData.push(modelData);
if (this._pivot) { if (this._pivot && models.pivot) {
const pivotData: any = {}; const pivotData: Record<string, unknown> = {};
for (const field of this._pivot) { for (const field of this._pivot) {
pivotData[field] = result[field.split('.')[1]]; pivotData[field] = result[field.split('.')[1]];
} }
models.pivot!.push(pivotData); models.pivot.push(pivotData);
} }
// Eager loading init map // Eager loading init map
for (const relation of this.relations) { for (const relation of this.relations) {
if (model[relation] === undefined) throw new Error(`Relation ${relation} doesn't exist on ${model.constructor.name}.`); if (model[relation] === undefined) throw new Error(`Relation ${relation} doesn't exist on ${model.constructor.name}.`);
relationMap[relation].push(model[relation]); if (!(model[relation] instanceof ModelRelation)) throw new Error(`Field ${relation} is not a relation on ${model.constructor.name}.`);
relationMap[relation].push(model[relation] as ModelRelation<Model, Model, Model | Model[] | null>);
} }
} }
@ -321,8 +350,9 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
this.fields.splice(0, this.fields.length); this.fields.splice(0, this.fields.length);
this.fields.push(new SelectFieldValue('_count', 'COUNT(*)', true)); this.fields.push(new SelectFieldValue('_count', 'COUNT(*)', true));
let queryResult = await this.execute(connection);
return queryResult.results[0]['_count']; const queryResult = await this.execute(connection);
return Number(queryResult.results[0]['_count']);
} }
} }
@ -338,9 +368,9 @@ function inputToFieldOrValue(input: string, addTable?: string): string {
} }
export interface ModelQueryResult<M extends Model> extends Array<M> { export interface ModelQueryResult<M extends Model> extends Array<M> {
originalData?: any[]; originalData?: Record<string, unknown>[];
pagination?: Pagination<M>; pagination?: Pagination<M>;
pivot?: { [p: string]: any }[]; pivot?: Record<string, unknown>[];
} }
export enum QueryType { export enum QueryType {
@ -366,19 +396,30 @@ export enum WhereTest {
class FieldValue { class FieldValue {
protected readonly field: string; protected readonly field: string;
protected value: any; protected value: ModelFieldData;
protected raw: boolean; protected raw: boolean;
constructor(field: string, value: any, raw: boolean) { public constructor(field: string, value: ModelFieldData, raw: boolean) {
this.field = field; this.field = field;
this.value = value; this.value = value;
this.raw = raw; this.raw = raw;
} }
public toString(first: boolean = true): string { public toString(first: boolean = true): string {
const valueStr = (this.raw || this.value === null || this.value instanceof ModelQuery) ? this.value : let valueStr: string;
(Array.isArray(this.value) ? `(${'?'.repeat(this.value.length).split('').join(',')})` : '?'); if (this.value instanceof ModelQuery) {
let field = inputToFieldOrValue(this.field); valueStr = this.value.toString(false);
} else if (this.value === null || this.value === undefined) {
valueStr = 'null';
} else if (this.raw) {
valueStr = this.value.toString();
} else {
valueStr = Array.isArray(this.value) ?
`(${'?'.repeat(this.value.length).split('').join(',')})` :
'?';
}
const field = inputToFieldOrValue(this.field);
return `${first ? '' : ','}${field}${this.test}${valueStr}`; return `${first ? '' : ','}${field}${this.test}${valueStr}`;
} }
@ -386,17 +427,33 @@ class FieldValue {
return '='; return '=';
} }
public get variables(): any[] { public get variables(): QueryVariable[] {
if (this.value instanceof ModelQuery) return this.value.variables; if (this.value instanceof ModelQuery) return this.value.variables;
if (this.raw || this.value === null) return []; if (this.raw || this.value === null || this.value === undefined) return [];
if (Array.isArray(this.value)) return this.value; if (Array.isArray(this.value)) return this.value.map(value => {
return [this.value]; if (!isQueryVariable(value)) value = value.toString();
return value;
}) as QueryVariable[];
let value = this.value;
if (!isQueryVariable(value)) value = value.toString();
return [value as QueryVariable];
} }
} }
export class SelectFieldValue extends FieldValue { export class SelectFieldValue extends FieldValue {
public toString(first: boolean = true): string { public toString(): string {
return `(${this.value instanceof ModelQuery ? this.value : (this.raw ? this.value : '?')}) AS \`${this.field}\``; let value: string;
if (this.value instanceof ModelQuery) {
value = this.value.toString(true);
} else if (this.value === null || this.value === undefined) {
value = 'null';
} else {
value = this.raw ?
this.value.toString() :
'?';
}
return `(${value}) AS \`${this.field}\``;
} }
} }
@ -407,7 +464,7 @@ class WhereFieldValue extends FieldValue {
private readonly _test: WhereTest; private readonly _test: WhereTest;
private readonly operator: WhereOperator; private readonly operator: WhereOperator;
constructor(field: string, value: any, raw: boolean, test: WhereTest, operator: WhereOperator) { public constructor(field: string, value: ModelFieldData, raw: boolean, test: WhereTest, operator: WhereOperator) {
super(field, value, raw); super(field, value, raw);
this._test = test; this._test = test;
this.operator = operator; this.operator = operator;
@ -451,7 +508,7 @@ class WhereFieldValueGroup {
} }
export interface WhereFieldConsumer<M extends Model> { export interface WhereFieldConsumer<M extends Model> {
where(field: string, value: string | Date | ModelQuery<any> | any, test?: WhereTest, operator?: WhereOperator): this; where(field: string, value: ModelFieldData, test?: WhereTest, operator?: WhereOperator): this;
groupWhere(setter: (query: WhereFieldConsumer<M>) => void, operator?: WhereOperator): this; groupWhere(setter: (query: WhereFieldConsumer<M>) => void, operator?: WhereOperator): this;
} }
@ -461,9 +518,15 @@ export type SelectFields = (string | SelectFieldValue | UpdateFieldValue)[];
export type SortDirection = 'ASC' | 'DESC'; export type SortDirection = 'ASC' | 'DESC';
type ModelQueryUnion = { type ModelQueryUnion = {
query: ModelQuery<any>, query: ModelQuery<Model>,
sortBy: string, sortBy: string,
direction: SortDirection, direction: SortDirection,
limit?: number, limit?: number,
offset?: number, offset?: number,
}; };
export type ModelFieldData =
| QueryVariable
| ModelQuery<Model>
| { toString(): string }
| (QueryVariable | { toString(): string })[];

View File

@ -1,4 +1,4 @@
import ModelQuery, {ModelQueryResult, WhereTest} from "./ModelQuery"; import ModelQuery, {ModelFieldData, ModelQueryResult, WhereTest} from "./ModelQuery";
import Model, {ModelType} from "./Model"; import Model, {ModelType} from "./Model";
import ModelFactory from "./ModelFactory"; import ModelFactory from "./ModelFactory";
@ -34,12 +34,12 @@ export default abstract class ModelRelation<S extends Model, O extends Model, R
return query; return query;
} }
public getModelID(): any { public getModelId(): ModelFieldData {
return this.model[this.dbProperties.localKey]; return this.model[this.dbProperties.localKey];
} }
protected applyRegularConstraints(query: ModelQuery<O>): void { protected applyRegularConstraints(query: ModelQuery<O>): void {
query.where(this.dbProperties.foreignKey, this.getModelID()); query.where(this.dbProperties.foreignKey, this.getModelId());
} }
public async get(): Promise<R> { public async get(): Promise<R> {
@ -69,10 +69,13 @@ export default abstract class ModelRelation<S extends Model, O extends Model, R
protected abstract collectionToOutput(models: O[]): R; protected abstract collectionToOutput(models: O[]): R;
public async eagerLoad(relations: ModelRelation<S, O, R>[], subRelations: string[] = []): Promise<ModelQueryResult<O>> { public async eagerLoad(
const ids = relations.map(r => r.getModelID()) relations: ModelRelation<S, O, R>[],
subRelations: string[] = [],
): Promise<ModelQueryResult<O>> {
const ids = relations.map(r => r.getModelId())
.filter(id => id !== null && id !== undefined) .filter(id => id !== null && id !== undefined)
.reduce((array: O[], val) => array.indexOf(val) >= 0 ? array : [...array, val], []); .reduce((array: ModelFieldData[], val) => array.indexOf(val) >= 0 ? array : [...array, val], []);
if (ids.length === 0) return []; if (ids.length === 0) return [];
const query = this.makeQuery(); const query = this.makeQuery();
@ -82,7 +85,7 @@ export default abstract class ModelRelation<S extends Model, O extends Model, R
} }
public async populate(models: ModelQueryResult<O>): Promise<void> { public async populate(models: ModelQueryResult<O>): Promise<void> {
this.cachedModels = models.filter(m => m[this.dbProperties.foreignKey] === this.getModelID()) this.cachedModels = models.filter(m => m[this.dbProperties.foreignKey] === this.getModelId())
.reduce((array: O[], val) => array.find(v => v.equals(val)) ? array : [...array, val], []); .reduce((array: O[], val) => array.find(v => v.equals(val)) ? array : [...array, val], []);
} }
@ -122,9 +125,13 @@ export class OneModelRelation<S extends Model, O extends Model> extends ModelRel
} }
export class ManyModelRelation<S extends Model, O extends Model> extends ModelRelation<S, O, O[]> { export class ManyModelRelation<S extends Model, O extends Model> extends ModelRelation<S, O, O[]> {
protected readonly paginatedCache: { [perPage: number]: { [pageNumber: number]: ModelQueryResult<O> } } = {}; protected readonly paginatedCache: {
[perPage: number]: {
[pageNumber: number]: ModelQueryResult<O> | undefined
} | undefined
} = {};
constructor(model: S, foreignModelType: ModelType<O>, dbProperties: RelationDatabaseProperties) { public constructor(model: S, foreignModelType: ModelType<O>, dbProperties: RelationDatabaseProperties) {
super(model, foreignModelType, dbProperties); super(model, foreignModelType, dbProperties);
} }
@ -141,29 +148,29 @@ export class ManyModelRelation<S extends Model, O extends Model> extends ModelRe
} }
public async paginate(page: number, perPage: number): Promise<ModelQueryResult<O>> { public async paginate(page: number, perPage: number): Promise<ModelQueryResult<O>> {
if (!this.paginatedCache[perPage]) this.paginatedCache[perPage] = {}; let cache = this.paginatedCache[perPage];
if (!cache) cache = this.paginatedCache[perPage] = {};
const cache = this.paginatedCache[perPage]; let cachePage = cache[page];
if (!cachePage) {
if (!cache[page]) {
const query = this.makeQuery(); const query = this.makeQuery();
this.applyRegularConstraints(query); this.applyRegularConstraints(query);
cache[page] = await query.paginate(page, perPage); cachePage = cache[page] = await query.paginate(page, perPage);
} }
return cache[page]; return cachePage;
} }
} }
export class ManyThroughModelRelation<S extends Model, O extends Model> extends ManyModelRelation<S, O> { export class ManyThroughModelRelation<S extends Model, O extends Model> extends ManyModelRelation<S, O> {
protected readonly dbProperties: PivotRelationDatabaseProperties; protected readonly dbProperties: PivotRelationDatabaseProperties;
constructor(model: S, foreignModelType: ModelType<O>, dbProperties: PivotRelationDatabaseProperties) { public constructor(model: S, foreignModelType: ModelType<O>, dbProperties: PivotRelationDatabaseProperties) {
super(model, foreignModelType, dbProperties); super(model, foreignModelType, dbProperties);
this.dbProperties = dbProperties; this.dbProperties = dbProperties;
this.constraint(query => query this.constraint(query => query
.leftJoin(this.dbProperties.pivotTable, 'pivot') .leftJoin(this.dbProperties.pivotTable, 'pivot')
.on(`pivot.${this.dbProperties.foreignPivotKey}`, `${this.foreignModelType.table}.${this.dbProperties.foreignKey}`) .on(`pivot.${this.dbProperties.foreignPivotKey}`, `${this.foreignModelType.table}.${this.dbProperties.foreignKey}`),
); );
} }
@ -176,12 +183,15 @@ export class ManyThroughModelRelation<S extends Model, O extends Model> extends
} }
protected applyRegularConstraints(query: ModelQuery<O>): void { protected applyRegularConstraints(query: ModelQuery<O>): void {
query.where(`pivot.${this.dbProperties.localPivotKey}`, this.getModelID()); query.where(`pivot.${this.dbProperties.localPivotKey}`, this.getModelId());
} }
public async eagerLoad(relations: ModelRelation<S, O, O[]>[], subRelations: string[] = []): Promise<ModelQueryResult<O>> { public async eagerLoad(
const ids = relations.map(r => r.getModelID()) relations: ModelRelation<S, O, O[]>[],
.reduce((array: O[], val) => array.indexOf(val) >= 0 ? array : [...array, val], []); subRelations: string[] = [],
): Promise<ModelQueryResult<O>> {
const ids = relations.map(r => r.getModelId())
.reduce((array: ModelFieldData[], val) => array.indexOf(val) >= 0 ? array : [...array, val], []);
if (ids.length === 0) return []; if (ids.length === 0) return [];
const query = this.makeQuery(); const query = this.makeQuery();
@ -192,8 +202,9 @@ export class ManyThroughModelRelation<S extends Model, O extends Model> extends
} }
public async populate(models: ModelQueryResult<O>): Promise<void> { public async populate(models: ModelQueryResult<O>): Promise<void> {
const ids = models.pivot! if (!models.pivot) throw new Error('ModelQueryResult.pivot not loaded.');
.filter(p => p[`pivot.${this.dbProperties.localPivotKey}`] === this.getModelID()) const ids = models.pivot
.filter(p => p[`pivot.${this.dbProperties.localPivotKey}`] === this.getModelId())
.map(p => p[`pivot.${this.dbProperties.foreignPivotKey}`]); .map(p => p[`pivot.${this.dbProperties.foreignPivotKey}`]);
this.cachedModels = models.filter(m => ids.indexOf(m[this.dbProperties.foreignKey]) >= 0) this.cachedModels = models.filter(m => ids.indexOf(m[this.dbProperties.foreignKey]) >= 0)
.reduce((array: O[], val) => array.find(v => v.equals(val)) ? array : [...array, val], []); .reduce((array: O[], val) => array.find(v => v.equals(val)) ? array : [...array, val], []);
@ -203,7 +214,12 @@ export class ManyThroughModelRelation<S extends Model, O extends Model> extends
export class RecursiveModelRelation<M extends Model> extends ManyModelRelation<M, M> { export class RecursiveModelRelation<M extends Model> extends ManyModelRelation<M, M> {
private readonly reverse: boolean; private readonly reverse: boolean;
public constructor(model: M, foreignModelType: ModelType<M>, dbProperties: RelationDatabaseProperties, reverse: boolean = false) { public constructor(
model: M,
foreignModelType: ModelType<M>,
dbProperties: RelationDatabaseProperties,
reverse: boolean = false,
) {
super(model, foreignModelType, dbProperties); super(model, foreignModelType, dbProperties);
this.constraint(query => query.recursive(this.dbProperties, reverse)); this.constraint(query => query.recursive(this.dbProperties, reverse));
this.reverse = reverse; this.reverse = reverse;
@ -215,17 +231,19 @@ export class RecursiveModelRelation<M extends Model> extends ManyModelRelation<M
public async populate(models: ModelQueryResult<M>): Promise<void> { public async populate(models: ModelQueryResult<M>): Promise<void> {
await super.populate(models); await super.populate(models);
if (this.cachedModels) { const cachedModels = this.cachedModels;
if (cachedModels) {
let count; let count;
do { do {
count = this.cachedModels.length; count = cachedModels.length;
this.cachedModels.push(...models.filter(model => cachedModels.push(...models.filter(model =>
!this.cachedModels!.find(cached => cached.equals(model)) && !cachedModels.find(cached => cached.equals(model)) && cachedModels.find(cached => {
this.cachedModels!.find(cached => cached[this.dbProperties.localKey] === model[this.dbProperties.foreignKey]) return cached[this.dbProperties.localKey] === model[this.dbProperties.foreignKey];
}),
).reduce((array: M[], val) => array.find(v => v.equals(val)) ? array : [...array, val], [])); ).reduce((array: M[], val) => array.find(v => v.equals(val)) ? array : [...array, val], []));
} while (count !== this.cachedModels.length); } while (count !== cachedModels.length);
if (this.reverse) this.cachedModels!.reverse(); if (this.reverse) cachedModels.reverse();
} }
} }

View File

@ -1,17 +1,21 @@
import mysql, {Connection, FieldInfo, Pool} from 'mysql'; import mysql, {Connection, FieldInfo, MysqlError, Pool, PoolConnection} from 'mysql';
import config from 'config'; import config from 'config';
import Migration from "./Migration"; import Migration, {MigrationType} from "./Migration";
import Logger from "../Logger"; import Logger from "../Logger";
import {Type} from "../Utils"; import {Type} from "../Utils";
export interface QueryResult { export interface QueryResult {
readonly results: any[]; readonly results: Record<string, unknown>[];
readonly fields: FieldInfo[]; readonly fields: FieldInfo[];
readonly other?: any; readonly other?: Record<string, unknown>;
foundRows?: number; foundRows?: number;
} }
export async function query(queryString: string, values?: any, connection?: Connection): Promise<QueryResult> { export async function query(
queryString: string,
values?: QueryVariable[],
connection?: Connection,
): Promise<QueryResult> {
return await MysqlConnectionManager.query(queryString, values, connection); return await MysqlConnectionManager.query(queryString, values, connection);
} }
@ -25,7 +29,7 @@ export default class MysqlConnectionManager {
return this.databaseReady && this.currentPool !== undefined; return this.databaseReady && this.currentPool !== undefined;
} }
public static registerMigrations(migrations: Type<Migration>[]) { public static registerMigrations(migrations: MigrationType<Migration>[]): void {
if (!this.migrationsRegistered) { if (!this.migrationsRegistered) {
this.migrationsRegistered = true; this.migrationsRegistered = true;
migrations.forEach(m => this.registerMigration(v => new m(v))); migrations.forEach(m => this.registerMigration(v => new m(v)));
@ -36,14 +40,14 @@ export default class MysqlConnectionManager {
this.migrations.push(migration(this.migrations.length + 1)); this.migrations.push(migration(this.migrations.length + 1));
} }
public static hasMigration(migration: Type<Migration>) { public static hasMigration(migration: Type<Migration>): boolean {
for (const m of this.migrations) { for (const m of this.migrations) {
if (m.constructor === migration) return true; if (m.constructor === migration) return true;
} }
return false; return false;
} }
public static async prepare(runMigrations: boolean = true) { public static async prepare(runMigrations: boolean = true): Promise<void> {
if (config.get('mysql.create_database_automatically') === true) { if (config.get('mysql.create_database_automatically') === true) {
const dbName = config.get('mysql.database'); const dbName = config.get('mysql.database');
Logger.info(`Creating database ${dbName}...`); Logger.info(`Creating database ${dbName}...`);
@ -55,11 +59,9 @@ export default class MysqlConnectionManager {
}); });
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
connection.query(`CREATE DATABASE IF NOT EXISTS ${dbName}`, (error) => { connection.query(`CREATE DATABASE IF NOT EXISTS ${dbName}`, (error) => {
if (error !== null) { return error !== null ?
reject(error); reject(error) :
} else {
resolve(); resolve();
}
}); });
}); });
connection.end(); connection.end();
@ -89,20 +91,24 @@ export default class MysqlConnectionManager {
} }
public static async endPool(): Promise<void> { public static async endPool(): Promise<void> {
return new Promise(resolve => { return await new Promise(resolve => {
if (this.currentPool !== undefined) { if (this.currentPool === undefined) {
return resolve();
}
this.currentPool.end(() => { this.currentPool.end(() => {
Logger.info('Mysql connection pool ended.'); Logger.info('Mysql connection pool ended.');
resolve(); resolve();
}); });
this.currentPool = undefined; this.currentPool = undefined;
} else {
resolve();
}
}); });
} }
public static async query(queryString: string, values?: any, connection?: Connection): Promise<QueryResult> { public static async query(
queryString: string,
values: QueryVariable[] = [],
connection?: Connection,
): Promise<QueryResult> {
return await new Promise<QueryResult>((resolve, reject) => { return await new Promise<QueryResult>((resolve, reject) => {
Logger.dev('SQL:', Logger.isVerboseMode() ? mysql.format(queryString, values) : queryString); Logger.dev('SQL:', Logger.isVerboseMode() ? mysql.format(queryString, values) : queryString);
@ -115,7 +121,7 @@ export default class MysqlConnectionManager {
resolve({ resolve({
results: Array.isArray(results) ? results : [], results: Array.isArray(results) ? results : [],
fields: fields !== undefined ? fields : [], fields: fields !== undefined ? fields : [],
other: Array.isArray(results) ? null : results other: Array.isArray(results) ? null : results,
}); });
}); });
}); });
@ -123,13 +129,13 @@ export default class MysqlConnectionManager {
public static async wrapTransaction<T>(transaction: (connection: Connection) => Promise<T>): Promise<T> { public static async wrapTransaction<T>(transaction: (connection: Connection) => Promise<T>): Promise<T> {
return await new Promise<T>((resolve, reject) => { return await new Promise<T>((resolve, reject) => {
this.pool.getConnection((err, connection) => { this.pool.getConnection((err: MysqlError | undefined, connection: PoolConnection) => {
if (err) { if (err) {
reject(err); reject(err);
return; return;
} }
connection.beginTransaction((err) => { connection.beginTransaction((err?: MysqlError) => {
if (err) { if (err) {
reject(err); reject(err);
this.pool.releaseConnection(connection); this.pool.releaseConnection(connection);
@ -137,7 +143,7 @@ export default class MysqlConnectionManager {
} }
transaction(connection).then(val => { transaction(connection).then(val => {
connection.commit((err) => { connection.commit((err?: MysqlError) => {
if (err) { if (err) {
this.rejectAndRollback(connection, err, reject); this.rejectAndRollback(connection, err, reject);
this.pool.releaseConnection(connection); this.pool.releaseConnection(connection);
@ -156,13 +162,15 @@ export default class MysqlConnectionManager {
}); });
} }
private static rejectAndRollback(connection: Connection, err: any, reject: (err: any) => void) { private static rejectAndRollback(
connection.rollback((rollbackErr) => { connection: Connection,
if (rollbackErr) { err: MysqlError | undefined,
reject(err + '\n' + rollbackErr); reject: (err: unknown) => void,
} else { ) {
connection.rollback((rollbackErr?: MysqlError) => {
return rollbackErr ?
reject(err + '\n' + rollbackErr) :
reject(err); reject(err);
}
}); });
} }
@ -171,7 +179,7 @@ export default class MysqlConnectionManager {
try { try {
const result = await query('SELECT id FROM migrations ORDER BY id DESC LIMIT 1'); const result = await query('SELECT id FROM migrations ORDER BY id DESC LIMIT 1');
currentVersion = result.results[0].id; currentVersion = Number(result.results[0]?.id);
} catch (e) { } catch (e) {
if (e.code === 'ECONNREFUSED' || e.code !== 'ER_NO_SUCH_TABLE') { if (e.code === 'ECONNREFUSED' || e.code !== 'ER_NO_SUCH_TABLE') {
throw new Error('Cannot run migrations: ' + e.code); throw new Error('Cannot run migrations: ' + e.code);
@ -197,16 +205,16 @@ export default class MysqlConnectionManager {
} }
for (const migration of this.migrations) { for (const migration of this.migrations) {
migration.registerModels(); migration.registerModels?.();
} }
} }
/** /**
* @param migrationID what migration to rollback. Use with caution. default=0 is for last registered migration. * @param migrationId what migration to rollback. Use with caution. default=0 is for last registered migration.
*/ */
public static async rollbackMigration(migrationID: number = 0): Promise<void> { public static async rollbackMigration(migrationId: number = 0): Promise<void> {
migrationID--; migrationId--;
const migration = this.migrations[migrationID]; const migration = this.migrations[migrationId];
Logger.info('Rolling back migration ', migration.version, migration.constructor.name); Logger.info('Rolling back migration ', migration.version, migration.constructor.name);
await MysqlConnectionManager.wrapTransaction<void>(async c => { await MysqlConnectionManager.wrapTransaction<void>(async c => {
await migration.rollback(c); await migration.rollback(c);
@ -220,12 +228,12 @@ export default class MysqlConnectionManager {
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
if (args[i] === 'rollback') { if (args[i] === 'rollback') {
let migrationID = 0; let migrationId = 0;
if (args.length > i + 1) { if (args.length > i + 1) {
migrationID = parseInt(args[i + 1]); migrationId = parseInt(args[i + 1]);
} }
await this.prepare(false); await this.prepare(false);
await this.rollbackMigration(migrationID); await this.rollbackMigration(migrationId);
return; return;
} }
} }
@ -234,3 +242,20 @@ export default class MysqlConnectionManager {
} }
} }
} }
export type QueryVariable =
| string
| number
| Date
| Buffer
| null
| undefined;
export function isQueryVariable(value: unknown): value is QueryVariable {
return typeof value === "string" ||
typeof value === 'number' ||
value instanceof Date ||
value instanceof Buffer ||
value === null ||
value === undefined;
}

View File

@ -1,20 +1,19 @@
import Model from "./Model"; import Model, {ModelType} from "./Model";
import ModelQuery, {WhereTest} from "./ModelQuery"; import ModelQuery, {WhereTest} from "./ModelQuery";
import {Connection} from "mysql"; import {Connection} from "mysql";
import {Type} from "../Utils";
import {ServerError} from "../HttpError"; import {ServerError} from "../HttpError";
export const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; export const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
export default class Validator<T> { export default class Validator<V> {
private readonly steps: ValidationStep<T>[] = []; private readonly steps: ValidationStep<V>[] = [];
private readonly validationAttributes: string[] = []; private readonly validationAttributes: string[] = [];
private readonly rawValueToHuman?: (val: T) => string; private readonly rawValueToHuman?: (val: V) => string;
private _min?: number; private _min?: number;
private _max?: number; private _max?: number;
public constructor(rawValueToHuman?: (val: T) => string) { public constructor(rawValueToHuman?: (val: V) => string) {
this.rawValueToHuman = rawValueToHuman; this.rawValueToHuman = rawValueToHuman;
} }
@ -24,8 +23,13 @@ export default class Validator<T> {
* @param onlyFormat {@code true} to only validate format properties, {@code false} otherwise. * @param onlyFormat {@code true} to only validate format properties, {@code false} otherwise.
* @param connection A connection to use in case of wrapped transactions. * @param connection A connection to use in case of wrapped transactions.
*/ */
async execute(thingName: string, value: T | undefined, onlyFormat: boolean, connection?: Connection): Promise<void> { public async execute(
const bag = new ValidationBag(); thingName: string,
value: V | undefined,
onlyFormat: boolean,
connection?: Connection,
): Promise<void> {
const bag = new ValidationBag<V>();
for (const step of this.steps) { for (const step of this.steps) {
if (onlyFormat && !step.isFormat) continue; if (onlyFormat && !step.isFormat) continue;
@ -41,7 +45,7 @@ export default class Validator<T> {
} }
if (result === false && step.throw) { if (result === false && step.throw) {
const error: ValidationError = step.throw(); const error: ValidationError<V> = step.throw();
error.rawValueToHuman = this.rawValueToHuman; error.rawValueToHuman = this.rawValueToHuman;
error.thingName = thingName; error.thingName = thingName;
error.value = value; error.value = value;
@ -57,7 +61,7 @@ export default class Validator<T> {
} }
} }
public defined(): Validator<T> { public defined(): Validator<V> {
this.validationAttributes.push('required'); this.validationAttributes.push('required');
this.addStep({ this.addStep({
@ -68,17 +72,21 @@ export default class Validator<T> {
return this; return this;
} }
public acceptUndefined(alsoAcceptEmptyString: boolean = false): Validator<T> { public acceptUndefined(alsoAcceptEmptyString: boolean = false): Validator<V> {
this.addStep({ this.addStep({
verifyStep: () => true, verifyStep: () => true,
throw: null, throw: null,
interrupt: val => val === undefined || val === null || (alsoAcceptEmptyString && typeof val === 'string' && val.length === 0), interrupt: val => {
return val === undefined ||
val === null ||
alsoAcceptEmptyString && typeof val === 'string' && val.length === 0;
},
isFormat: true, isFormat: true,
}); });
return this; return this;
} }
public equals(other?: T): Validator<T> { public equals(other?: V): Validator<V> {
this.addStep({ this.addStep({
verifyStep: val => val === other, verifyStep: val => val === other,
throw: () => new BadValueValidationError(other), throw: () => new BadValueValidationError(other),
@ -87,7 +95,7 @@ export default class Validator<T> {
return this; return this;
} }
public sameAs(otherName?: string, other?: T): Validator<T> { public sameAs(otherName?: string, other?: V): Validator<V> {
this.addStep({ this.addStep({
verifyStep: val => val === other, verifyStep: val => val === other,
throw: () => new DifferentThanError(otherName), throw: () => new DifferentThanError(otherName),
@ -96,7 +104,7 @@ export default class Validator<T> {
return this; return this;
} }
public regexp(regexp: RegExp): Validator<T> { public regexp(regexp: RegExp): Validator<V> {
this.validationAttributes.push(`pattern="${regexp}"`); this.validationAttributes.push(`pattern="${regexp}"`);
this.addStep({ this.addStep({
verifyStep: val => regexp.test(<string><unknown>val), verifyStep: val => regexp.test(<string><unknown>val),
@ -106,9 +114,9 @@ export default class Validator<T> {
return this; return this;
} }
public length(length: number): Validator<T> { public length(length: number): Validator<V> {
this.addStep({ this.addStep({
verifyStep: val => (<any>val).length === length, verifyStep: val => isLenghtable(val) && val.length === length,
throw: () => new BadLengthValidationError(length), throw: () => new BadLengthValidationError(length),
isFormat: true, isFormat: true,
}); });
@ -118,9 +126,9 @@ export default class Validator<T> {
/** /**
* @param minLength included * @param minLength included
*/ */
public minLength(minLength: number): Validator<T> { public minLength(minLength: number): Validator<V> {
this.addStep({ this.addStep({
verifyStep: val => (<any>val).length >= minLength, verifyStep: val => isLenghtable(val) && val.length >= minLength,
throw: () => new TooShortError(minLength), throw: () => new TooShortError(minLength),
isFormat: true, isFormat: true,
}); });
@ -130,9 +138,9 @@ export default class Validator<T> {
/** /**
* @param maxLength included * @param maxLength included
*/ */
public maxLength(maxLength: number): Validator<T> { public maxLength(maxLength: number): Validator<V> {
this.addStep({ this.addStep({
verifyStep: val => (<any>val).length <= maxLength, verifyStep: val => isLenghtable(val) && val.length <= maxLength,
throw: () => new TooLongError(maxLength), throw: () => new TooLongError(maxLength),
isFormat: true, isFormat: true,
}); });
@ -143,11 +151,11 @@ export default class Validator<T> {
* @param minLength included * @param minLength included
* @param maxLength included * @param maxLength included
*/ */
public between(minLength: number, maxLength: number): Validator<T> { public between(minLength: number, maxLength: number): Validator<V> {
this.addStep({ this.addStep({
verifyStep: val => { verifyStep: val => {
const length = (<any>val).length; return isLenghtable(val) &&
return length >= minLength && length <= maxLength; val.length >= minLength && val.length <= maxLength;
}, },
throw: () => new BadLengthValidationError(minLength, maxLength), throw: () => new BadLengthValidationError(minLength, maxLength),
isFormat: true, isFormat: true,
@ -158,12 +166,12 @@ export default class Validator<T> {
/** /**
* @param min included * @param min included
*/ */
public min(min: number): Validator<T> { public min(min: number): Validator<V> {
this.validationAttributes.push(`min="${min}"`); this.validationAttributes.push(`min="${min}"`);
this._min = min; this._min = min;
this.addStep({ this.addStep({
verifyStep: val => { verifyStep: val => {
return (<any>val) >= min; return typeof val === 'number' && val >= min;
}, },
throw: () => new OutOfRangeValidationError(this._min, this._max), throw: () => new OutOfRangeValidationError(this._min, this._max),
isFormat: true, isFormat: true,
@ -174,12 +182,12 @@ export default class Validator<T> {
/** /**
* @param max included * @param max included
*/ */
public max(max: number): Validator<T> { public max(max: number): Validator<V> {
this.validationAttributes.push(`max="${max}"`); this.validationAttributes.push(`max="${max}"`);
this._max = max; this._max = max;
this.addStep({ this.addStep({
verifyStep: val => { verifyStep: val => {
return (<any>val) <= max; return typeof val === 'number' && val <= max;
}, },
throw: () => new OutOfRangeValidationError(this._min, this._max), throw: () => new OutOfRangeValidationError(this._min, this._max),
isFormat: true, isFormat: true,
@ -187,35 +195,46 @@ export default class Validator<T> {
return this; return this;
} }
public unique<M extends Model>(model: M | Type<M>, foreignKey?: string, querySupplier?: () => ModelQuery<M>): Validator<T> { public unique<M extends Model>(
model: M | ModelType<M>,
foreignKey?: string,
querySupplier?: () => ModelQuery<M>,
): Validator<V> {
this.addStep({ this.addStep({
verifyStep: async (val, thingName, c) => { verifyStep: async (val, thingName, c) => {
if (!foreignKey) foreignKey = thingName; if (!foreignKey) foreignKey = thingName;
let query: ModelQuery<M>; let query: ModelQuery<M>;
if (querySupplier) { if (querySupplier) {
query = querySupplier().where(foreignKey, val); query = querySupplier();
} else { } else {
query = (model instanceof Model ? <any>model.constructor : model).select('').where(foreignKey, val); query = (model instanceof Model ? <ModelType<M>>model.constructor : model).select('');
} }
if (model instanceof Model && typeof model.id === 'number') query = query.where('id', model.id, WhereTest.NE); query.where(foreignKey, val);
if (model instanceof Model && typeof model.id === 'number')
query = query.where('id', model.id, WhereTest.NE);
return (await query.execute(c)).results.length === 0; return (await query.execute(c)).results.length === 0;
}, },
throw: () => new AlreadyExistsValidationError((<any>model).table), throw: () => new AlreadyExistsValidationError(model.table),
isFormat: false, isFormat: false,
}); });
return this; return this;
} }
public exists(modelClass: Function, foreignKey?: string): Validator<T> { public exists(modelType: ModelType<Model>, foreignKey?: string): Validator<V> {
this.addStep({ this.addStep({
verifyStep: async (val, thingName, c) => (await (<any>modelClass).select('').where(foreignKey !== undefined ? foreignKey : thingName, val).execute(c)).results.length >= 1, verifyStep: async (val, thingName, c) => {
throw: () => new UnknownRelationValidationError((<any>modelClass).table, foreignKey), const results = await modelType.select('')
.where(foreignKey !== undefined ? foreignKey : thingName, val)
.execute(c);
return results.results.length >= 1;
},
throw: () => new UnknownRelationValidationError(modelType.table, foreignKey),
isFormat: false, isFormat: false,
}); });
return this; return this;
} }
private addStep(step: ValidationStep<T>) { private addStep(step: ValidationStep<V>) {
this.steps.push(step); this.steps.push(step);
} }
@ -223,31 +242,31 @@ export default class Validator<T> {
return this.validationAttributes; return this.validationAttributes;
} }
public step(step: number): Validator<T> { public step(step: number): Validator<V> {
this.validationAttributes.push(`step="${step}"`); this.validationAttributes.push(`step="${step}"`);
return this; return this;
} }
} }
interface ValidationStep<T> { interface ValidationStep<V> {
interrupt?: (val?: T) => boolean; interrupt?: (val?: V) => boolean;
verifyStep(val: T | undefined, thingName: string, connection?: Connection): boolean | Promise<boolean>; verifyStep(val: V | undefined, thingName: string, connection?: Connection): boolean | Promise<boolean>;
throw: ((val?: T) => ValidationError) | null; throw: ((val?: V) => ValidationError<V>) | null;
readonly isFormat: boolean; readonly isFormat: boolean;
} }
export class ValidationBag extends Error { export class ValidationBag<V> extends Error {
private readonly errors: ValidationError[] = []; private readonly errors: ValidationError<V>[] = [];
public addMessage(err: ValidationError) { public addMessage(err: ValidationError<V>): void {
if (!err.thingName) throw new Error('Null thing name'); if (!err.thingName) throw new Error('Null thing name');
this.errors.push(err); this.errors.push(err);
} }
public addBag(otherBag: ValidationBag) { public addBag(otherBag: ValidationBag<V>): void {
for (const error of otherBag.errors) { for (const error of otherBag.errors) {
this.errors.push(error); this.errors.push(error);
} }
@ -257,10 +276,10 @@ export class ValidationBag extends Error {
return this.errors.length > 0; return this.errors.length > 0;
} }
public getMessages(): { [p: string]: ValidationError } { public getMessages(): { [p: string]: ValidationError<V> } {
const messages: { [p: string]: ValidationError } = {}; const messages: { [p: string]: ValidationError<V> } = {};
for (const err of this.errors) { for (const err of this.errors) {
messages[err.thingName!] = { messages[err.thingName || 'unknown'] = {
name: err.name, name: err.name,
message: err.message, message: err.message,
value: err.value, value: err.value,
@ -269,26 +288,26 @@ export class ValidationBag extends Error {
return messages; return messages;
} }
public getErrors(): ValidationError[] { public getErrors(): ValidationError<V>[] {
return this.errors; return this.errors;
} }
} }
export abstract class ValidationError extends Error { export abstract class ValidationError<V> extends Error {
public rawValueToHuman?: (val: any) => string; public rawValueToHuman?: (val: V) => string;
public thingName?: string; public thingName?: string;
public value?: any; public value?: V;
public get name(): string { public get name(): string {
return this.constructor.name; return this.constructor.name;
} }
} }
export class BadLengthValidationError extends ValidationError { export class BadLengthValidationError<V> extends ValidationError<V> {
private readonly expectedLength: number; private readonly expectedLength: number;
private readonly maxLength?: number; private readonly maxLength?: number;
constructor(expectedLength: number, maxLength?: number) { public constructor(expectedLength: number, maxLength?: number) {
super(); super();
this.expectedLength = expectedLength; this.expectedLength = expectedLength;
this.maxLength = maxLength; this.maxLength = maxLength;
@ -296,14 +315,14 @@ export class BadLengthValidationError extends ValidationError {
public get message(): string { public get message(): string {
return `${this.thingName} expected length: ${this.expectedLength}${this.maxLength !== undefined ? ` to ${this.maxLength}` : ''}; ` + return `${this.thingName} expected length: ${this.expectedLength}${this.maxLength !== undefined ? ` to ${this.maxLength}` : ''}; ` +
`actual length: ${this.value.length}.`; `actual length: ${isLenghtable(this.value) && this.value.length}.`;
} }
} }
export class TooShortError extends ValidationError { export class TooShortError<V> extends ValidationError<V> {
private readonly minLength: number; private readonly minLength: number;
constructor(minLength: number) { public constructor(minLength: number) {
super(); super();
this.minLength = minLength; this.minLength = minLength;
} }
@ -313,10 +332,10 @@ export class TooShortError extends ValidationError {
} }
} }
export class TooLongError extends ValidationError { export class TooLongError<V> extends ValidationError<V> {
private readonly maxLength: number; private readonly maxLength: number;
constructor(maxLength: number) { public constructor(maxLength: number) {
super(); super();
this.maxLength = maxLength; this.maxLength = maxLength;
} }
@ -326,43 +345,43 @@ export class TooLongError extends ValidationError {
} }
} }
export class BadValueValidationError extends ValidationError { export class BadValueValidationError<V> extends ValidationError<V> {
private readonly expectedValue: any; private readonly expectedValue: V;
constructor(expectedValue: any) { public constructor(expectedValue: V) {
super(); super();
this.expectedValue = expectedValue; this.expectedValue = expectedValue;
} }
public get message(): string { public get message(): string {
let expectedValue = this.expectedValue; let expectedValue: string = String(this.expectedValue);
let actualValue = this.value; let actualValue: string = String(this.value);
if (this.rawValueToHuman) { if (this.rawValueToHuman && this.value) {
expectedValue = this.rawValueToHuman(expectedValue); expectedValue = this.rawValueToHuman(this.expectedValue);
actualValue = this.rawValueToHuman(actualValue); actualValue = this.rawValueToHuman(this.value);
} }
return `Expected: ${expectedValue}; got: ${actualValue}.` return `Expected: ${expectedValue}; got: ${actualValue}.`;
} }
} }
export class DifferentThanError extends ValidationError { export class DifferentThanError<V> extends ValidationError<V> {
private readonly otherName: any; private readonly otherName?: string;
constructor(otherName: any) { public constructor(otherName?: string) {
super(); super();
this.otherName = otherName; this.otherName = otherName;
} }
public get message(): string { public get message(): string {
return `This should be the same as ${this.otherName}.` return `This should be the same as ${this.otherName}.`;
} }
} }
export class OutOfRangeValidationError extends ValidationError { export class OutOfRangeValidationError<V> extends ValidationError<V> {
private readonly min?: number; private readonly min?: number;
private readonly max?: number; private readonly max?: number;
constructor(min?: number, max?: number) { public constructor(min?: number, max?: number) {
super(); super();
this.min = min; this.min = min;
this.max = max; this.max = max;
@ -374,32 +393,32 @@ export class OutOfRangeValidationError extends ValidationError {
} else if (this.max === undefined) { } else if (this.max === undefined) {
return `${this.thingName} must be at least ${this.min}`; return `${this.thingName} must be at least ${this.min}`;
} }
let min: any = this.min; let min: string = String(this.min);
let max: any = this.max; let max: string = String(this.max);
if (this.rawValueToHuman) { if (this.rawValueToHuman) {
min = this.rawValueToHuman(min); min = this.rawValueToHuman(this.min as unknown as V);
max = this.rawValueToHuman(max); max = this.rawValueToHuman(this.max as unknown as V);
} }
return `${this.thingName} must be between ${min} and ${max}.`; return `${this.thingName} must be between ${min} and ${max}.`;
} }
} }
export class InvalidFormatValidationError extends ValidationError { export class InvalidFormatValidationError<V> extends ValidationError<V> {
public get message(): string { public get message(): string {
return `"${this.value}" is not a valid ${this.thingName}.`; return `"${this.value}" is not a valid ${this.thingName}.`;
} }
} }
export class UndefinedValueValidationError extends ValidationError { export class UndefinedValueValidationError<V> extends ValidationError<V> {
public get message(): string { public get message(): string {
return `${this.thingName} is required.`; return `${this.thingName} is required.`;
} }
} }
export class AlreadyExistsValidationError extends ValidationError { export class AlreadyExistsValidationError<V> extends ValidationError<V> {
private readonly table: string; private readonly table: string;
constructor(table: string) { public constructor(table: string) {
super(); super();
this.table = table; this.table = table;
} }
@ -409,11 +428,11 @@ export class AlreadyExistsValidationError extends ValidationError {
} }
} }
export class UnknownRelationValidationError extends ValidationError { export class UnknownRelationValidationError<V> extends ValidationError<V> {
private readonly table: string; private readonly table: string;
private readonly foreignKey?: string; private readonly foreignKey?: string;
constructor(table: string, foreignKey?: string) { public constructor(table: string, foreignKey?: string) {
super(); super();
this.table = table; this.table = table;
this.foreignKey = foreignKey; this.foreignKey = foreignKey;
@ -424,15 +443,24 @@ export class UnknownRelationValidationError extends ValidationError {
} }
} }
export class FileError extends ValidationError { export class FileError<V> extends ValidationError<V> {
private readonly m: string; private readonly _message: string;
constructor(message: string) { public constructor(message: string) {
super(); super();
this.m = message; this._message = message;
} }
public get message(): string { public get message(): string {
return `${this.m}`; return `${this._message}`;
} }
} }
export type Lengthable = {
length: number,
};
export function isLenghtable(value: unknown): value is Lengthable {
return value !== undefined && value !== null &&
typeof (value as Lengthable).length === 'number';
}

View File

@ -39,9 +39,12 @@ export default class BackendController extends Controller {
public routes(): void { public routes(): void {
this.get('/', this.getIndex, 'backend', RequireAuthMiddleware, RequireAdminMiddleware); this.get('/', this.getIndex, 'backend', RequireAuthMiddleware, RequireAdminMiddleware);
if (User.isApprovalMode()) { if (User.isApprovalMode()) {
this.get('/accounts-approval', this.getAccountApproval, 'accounts-approval', RequireAuthMiddleware, RequireAdminMiddleware); this.get('/accounts-approval', this.getAccountApproval, 'accounts-approval',
this.post('/accounts-approval/approve', this.postApproveAccount, 'approve-account', RequireAuthMiddleware, RequireAdminMiddleware); RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/accounts-approval/reject', this.postRejectAccount, 'reject-account', RequireAuthMiddleware, RequireAdminMiddleware); this.post('/accounts-approval/approve', this.postApproveAccount, 'approve-account',
RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/accounts-approval/reject', this.postRejectAccount, 'reject-account',
RequireAuthMiddleware, RequireAdminMiddleware);
} }
} }
@ -71,11 +74,11 @@ export default class BackendController extends Controller {
account.as(UserApprovedComponent).approved = true; account.as(UserApprovedComponent).approved = true;
await account.save(); await account.save();
if (email) { if (email && email.email) {
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, { await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: true, approved: true,
link: config.get<string>('base_url') + Controller.route('auth'), link: config.get<string>('base_url') + Controller.route('auth'),
}).send(email.email!); }).send(email.email);
} }
req.flash('success', `Account successfully approved.`); req.flash('success', `Account successfully approved.`);
@ -87,10 +90,10 @@ export default class BackendController extends Controller {
await account.delete(); await account.delete();
if (email) { if (email && email.email) {
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, { await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: false, approved: false,
}).send(email.email!); }).send(email.email);
} }
req.flash('success', `Account successfully deleted.`); req.flash('success', `Account successfully deleted.`);

View File

@ -31,7 +31,4 @@ export default class CreateMigrationsTable extends Migration {
public async rollback(connection: Connection): Promise<void> { public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE migrations', connection); await this.query('DROP TABLE migrations', connection);
} }
public registerModels(): void {
}
} }

View File

@ -1,6 +1,6 @@
import Model from "../db/Model"; import Model from "../db/Model";
import {LogLevel, LogLevelKeys} from "../Logger"; import {LogLevel, LogLevelKeys} from "../Logger";
import {bufferToUUID} from "../Utils"; import {bufferToUuid} from "../Utils";
export default class Log extends Model { export default class Log extends Model {
public readonly id?: number = undefined; public readonly id?: number = undefined;
@ -26,15 +26,15 @@ export default class Log extends Model {
return <LogLevelKeys>LogLevel[this.level]; return <LogLevelKeys>LogLevel[this.level];
} }
public setLevel(level: LogLevelKeys) { public setLevel(level: LogLevel): void {
this.level = LogLevel[level]; this.level = level;
} }
public getLogID(): string | null { public getLogId(): string | null {
return this.log_id ? bufferToUUID(this.log_id!) : null; return this.log_id ? bufferToUuid(this.log_id) : null;
} }
public setLogID(buffer: Buffer) { public setLogId(buffer: Buffer): void {
this.log_id = buffer; this.log_id = buffer;
} }
@ -50,7 +50,7 @@ export default class Log extends Model {
return this.error_stack || ''; return this.error_stack || '';
} }
public setError(error?: Error) { public setError(error?: Error): void {
if (!error) return; if (!error) return;
this.error_name = error.name; this.error_name = error.name;

View File

@ -1,10 +1,14 @@
import {Files} from "formidable"; import {Files} from "formidable";
import {Type} from "../Utils"; import {Type} from "../Utils";
import Middleware from "../Middleware"; import Middleware from "../Middleware";
import {FlashMessages} from "../components/SessionComponent";
declare global { declare global {
namespace Express { namespace Express {
export interface Request { export interface Request {
getSession(): Session;
files: Files; files: Files;
@ -13,15 +17,15 @@ declare global {
as<M extends Middleware>(type: Type<M>): M; as<M extends Middleware>(type: Type<M>): M;
flash(): { [key: string]: string[] }; flash(): FlashMessages;
flash(message: string): any; flash(message: string): unknown[];
flash(event: string, message: any): any; flash(event: string, message: string): void;
} }
export interface Response { export interface Response {
redirectBack(defaultUrl?: string): any; redirectBack(defaultUrl?: string): void;
} }
} }
} }

View File

@ -8,12 +8,12 @@ useApp((addr, port) => {
return app = new class extends TestApp { return app = new class extends TestApp {
protected async init(): Promise<void> { protected async init(): Promise<void> {
this.use(new class extends Controller { this.use(new class extends Controller {
routes(): void { public routes(): void {
this.get('/', (req, res, next) => { this.get('/', (req, res) => {
res.render('test/csrf.njk'); res.render('test/csrf.njk');
}, 'csrf_test'); }, 'csrf_test');
this.post('/', (req, res, next) => { this.post('/', (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',
}); });

View File

@ -43,9 +43,6 @@ class Author extends Model {
localPivotKey: 'author_id', localPivotKey: 'author_id',
foreignPivotKey: 'role_id', foreignPivotKey: 'role_id',
}); });
protected init(): void {
}
} }
class Role extends Model { class Role extends Model {
@ -59,17 +56,11 @@ class Role extends Model {
localPivotKey: 'role_id', localPivotKey: 'role_id',
foreignPivotKey: 'permission_id', foreignPivotKey: 'permission_id',
}); });
protected init(): void {
}
} }
class Permission extends Model { class Permission extends Model {
public id?: number = undefined; public id?: number = undefined;
public name?: string = undefined; public name?: string = undefined;
protected init(): void {
}
} }
class AuthorRole extends Model { class AuthorRole extends Model {
@ -133,12 +124,12 @@ beforeAll(async () => {
postFactory, postFactory,
authorFactory, authorFactory,
roleFactory, roleFactory,
permissionFactory permissionFactory,
]) { ]) {
await MysqlConnectionManager.query(`DROP TABLE IF EXISTS ${(factory.table)}`); await MysqlConnectionManager.query(`DROP TABLE IF EXISTS ${factory.table}`);
} }
await MysqlConnectionManager.query(`CREATE TABLE ${(fakeDummyModelModelFactory.table)}( await MysqlConnectionManager.query(`CREATE TABLE ${fakeDummyModelModelFactory.table}(
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(256), name VARCHAR(256),
date DATETIME, date DATETIME,
@ -146,24 +137,24 @@ beforeAll(async () => {
PRIMARY KEY(id) PRIMARY KEY(id)
)`); )`);
await MysqlConnectionManager.query(`CREATE TABLE ${(authorFactory.table)}( await MysqlConnectionManager.query(`CREATE TABLE ${authorFactory.table}(
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(64), name VARCHAR(64),
PRIMARY KEY(id) PRIMARY KEY(id)
)`); )`);
await MysqlConnectionManager.query(`CREATE TABLE ${(postFactory.table)}( await MysqlConnectionManager.query(`CREATE TABLE ${postFactory.table}(
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
author_id INT NOT NULL, author_id INT NOT NULL,
content VARCHAR(512), content VARCHAR(512),
PRIMARY KEY(id), PRIMARY KEY(id),
FOREIGN KEY post_author_fk (author_id) REFERENCES ${(authorFactory.table)} (id) FOREIGN KEY post_author_fk (author_id) REFERENCES ${authorFactory.table} (id)
)`); )`);
await MysqlConnectionManager.query(`CREATE TABLE ${(roleFactory.table)}( await MysqlConnectionManager.query(`CREATE TABLE ${roleFactory.table}(
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(64), name VARCHAR(64),
PRIMARY KEY(id) PRIMARY KEY(id)
)`); )`);
await MysqlConnectionManager.query(`CREATE TABLE ${(permissionFactory.table)}( await MysqlConnectionManager.query(`CREATE TABLE ${permissionFactory.table}(
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(64), name VARCHAR(64),
PRIMARY KEY(id) PRIMARY KEY(id)
@ -174,16 +165,16 @@ beforeAll(async () => {
author_id INT NOT NULL, author_id INT NOT NULL,
role_id INT NOT NULL, role_id INT NOT NULL,
PRIMARY KEY(id), PRIMARY KEY(id),
FOREIGN KEY author_role_author_fk (author_id) REFERENCES ${(authorFactory.table)} (id), FOREIGN KEY author_role_author_fk (author_id) REFERENCES ${authorFactory.table} (id),
FOREIGN KEY author_role_role_fk (role_id) REFERENCES ${(roleFactory.table)} (id) FOREIGN KEY author_role_role_fk (role_id) REFERENCES ${roleFactory.table} (id)
)`); )`);
await MysqlConnectionManager.query(`CREATE TABLE role_permission( await MysqlConnectionManager.query(`CREATE TABLE role_permission(
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
role_id INT NOT NULL, role_id INT NOT NULL,
permission_id INT NOT NULL, permission_id INT NOT NULL,
PRIMARY KEY(id), PRIMARY KEY(id),
FOREIGN KEY role_permission_role_fk (role_id) REFERENCES ${(roleFactory.table)} (id), FOREIGN KEY role_permission_role_fk (role_id) REFERENCES ${roleFactory.table} (id),
FOREIGN KEY role_permission_permission_fk (permission_id) REFERENCES ${(permissionFactory.table)} (id) FOREIGN KEY role_permission_permission_fk (permission_id) REFERENCES ${permissionFactory.table} (id)
)`); )`);
@ -289,10 +280,10 @@ describe('Model', () => {
// Check that row exists in DB // Check that row exists in DB
const retrievedInstance = await FakeDummyModel.getById(1); const retrievedInstance = await FakeDummyModel.getById(1);
expect(retrievedInstance).toBeDefined(); expect(retrievedInstance).toBeDefined();
expect(retrievedInstance!.id).toBe(1); expect(retrievedInstance?.id).toBe(1);
expect(retrievedInstance!.name).toBe('name1'); expect(retrievedInstance?.name).toBe('name1');
expect(retrievedInstance!.date?.getTime()).toBeCloseTo(date.getTime(), -4); expect(retrievedInstance?.date?.getTime()).toBeCloseTo(date.getTime(), -4);
expect(retrievedInstance!.date_default).toBeDefined(); expect(retrievedInstance?.date_default).toBeDefined();
const failingInsertModel = fakeDummyModelModelFactory.create({ const failingInsertModel = fakeDummyModelModelFactory.create({
name: 'a', name: 'a',
@ -308,17 +299,19 @@ describe('Model', () => {
const preUpdatedModel = await FakeDummyModel.getById(insertModel.id); const preUpdatedModel = await FakeDummyModel.getById(insertModel.id);
expect(preUpdatedModel).not.toBeNull(); expect(preUpdatedModel).not.toBeNull();
expect(preUpdatedModel!.name).toBe(insertModel.name); expect(preUpdatedModel?.name).toBe(insertModel.name);
// Update model // Update model
preUpdatedModel!.name = 'updated_name'; if (preUpdatedModel) {
await preUpdatedModel!.save(); preUpdatedModel.name = 'updated_name';
await preUpdatedModel.save();
}
const postUpdatedModel = await FakeDummyModel.getById(insertModel.id); const postUpdatedModel = await FakeDummyModel.getById(insertModel.id);
expect(postUpdatedModel).not.toBeNull(); expect(postUpdatedModel).not.toBeNull();
expect(postUpdatedModel!.id).toBe(insertModel.id); expect(postUpdatedModel?.id).toBe(insertModel.id);
expect(postUpdatedModel!.name).not.toBe(insertModel.name); expect(postUpdatedModel?.name).not.toBe(insertModel.name);
expect(postUpdatedModel!.name).toBe(preUpdatedModel!.name); expect(postUpdatedModel?.name).toBe(preUpdatedModel?.name);
}); });
it('should delete properly', async () => { it('should delete properly', async () => {
@ -330,7 +323,7 @@ describe('Model', () => {
const preDeleteModel = await FakeDummyModel.getById(insertModel.id); const preDeleteModel = await FakeDummyModel.getById(insertModel.id);
expect(preDeleteModel).not.toBeNull(); expect(preDeleteModel).not.toBeNull();
await preDeleteModel!.delete(); await preDeleteModel?.delete();
const postDeleteModel = await FakeDummyModel.getById(insertModel.id); const postDeleteModel = await FakeDummyModel.getById(insertModel.id);
expect(postDeleteModel).toBeNull(); expect(postDeleteModel).toBeNull();
@ -359,21 +352,28 @@ describe('ModelRelation', () => {
expect(posts.length).toBe(3); expect(posts.length).toBe(3);
async function testPost(post: Post, originalPost: Post, expectedAuthor: Author, expectedRoles: Role[], expectedPermissions: Permission[]) { async function testPost(
console.log('Testing post', post) post: Post,
originalPost: Post,
expectedAuthor: Author,
expectedRoles: Role[],
expectedPermissions: Permission[],
) {
console.log('Testing post', post);
expect(post.id).toBe(originalPost.id); expect(post.id).toBe(originalPost.id);
expect(post.content).toBe(originalPost.content); expect(post.content).toBe(originalPost.content);
const actualAuthor = await post.author.get(); const actualAuthor = await post.author.get();
expect(actualAuthor).not.toBeNull() expect(actualAuthor).not.toBeNull();
expect(await post.author.has(expectedAuthor)).toBeTruthy(); expect(await post.author.has(expectedAuthor)).toBeTruthy();
expect(actualAuthor!.equals(expectedAuthor)).toBe(true); expect(actualAuthor?.equals(expectedAuthor)).toBe(true);
const authorRoles = await actualAuthor!.roles.get(); const authorRoles = await actualAuthor?.roles.get() || [];
console.log('Roles:'); console.log('Roles:');
expect(authorRoles.map(r => r.id)).toStrictEqual(expectedRoles.map(r => r.id)); expect(authorRoles.map(r => r.id)).toStrictEqual(expectedRoles.map(r => r.id));
const authorPermissions = (await Promise.all(authorRoles.map(async r => await r.permissions.get()))).flatMap(p => p); const authorPermissions = (await Promise.all(authorRoles.map(async r => await r.permissions.get())))
.flatMap(p => p);
console.log('Permissions:'); console.log('Permissions:');
expect(authorPermissions.map(p => p.id)).toStrictEqual(expectedPermissions.map(p => p.id)); expect(authorPermissions.map(p => p.id)).toStrictEqual(expectedPermissions.map(p => p.id));
} }

View File

@ -31,7 +31,11 @@ describe('Test ModelQuery', () => {
}); });
test('function select', () => { test('function select', () => {
const query = ModelQuery.select({table: 'model'} as unknown as ModelFactory<Model>, 'f1', new SelectFieldValue('_count', 'COUNT(*)', true)); const query = ModelQuery.select(
{table: 'model'} as unknown as ModelFactory<Model>,
'f1',
new SelectFieldValue('_count', 'COUNT(*)', true),
);
expect(query.toString(true)).toBe('SELECT `model`.`f1`,(COUNT(*)) AS `_count` FROM `model`'); expect(query.toString(true)).toBe('SELECT `model`.`f1`,(COUNT(*)) AS `_count` FROM `model`');
expect(query.variables).toStrictEqual([]); expect(query.variables).toStrictEqual([]);
}); });
@ -81,6 +85,6 @@ describe('Test ModelQuery', () => {
query.union(query2, 'model.f1', 'DESC', false, 8); query.union(query2, 'model.f1', 'DESC', false, 8);
expect(query.toString(true)).toBe("(SELECT `model`.* FROM `model`) UNION (SELECT `model2`.* FROM `model2` WHERE `f2`=?) ORDER BY `model`.`f1` DESC LIMIT 8"); expect(query.toString(true)).toBe("(SELECT `model`.* FROM `model`) UNION (SELECT `model2`.* FROM `model2` WHERE `f2`=?) ORDER BY `model`.`f1` DESC LIMIT 8");
expect(query.variables).toStrictEqual(['v2']) expect(query.variables).toStrictEqual(['v2']);
}); });
}); });

View File

@ -1,7 +1,6 @@
import {setupMailServer, teardownMailServer} from "./_mail_server"; import {setupMailServer, teardownMailServer} from "./_mail_server";
import Application from "../src/Application"; import Application from "../src/Application";
import Migration from "../src/db/Migration"; import Migration, {MigrationType} from "../src/db/Migration";
import {Type} from "../src/Utils";
import {MIGRATIONS} from "./_migrations"; import {MIGRATIONS} from "./_migrations";
import ExpressAppComponent from "../src/components/ExpressAppComponent"; import ExpressAppComponent from "../src/components/ExpressAppComponent";
import RedisComponent from "../src/components/RedisComponent"; import RedisComponent from "../src/components/RedisComponent";
@ -17,8 +16,9 @@ import FormHelperComponent from "../src/components/FormHelperComponent";
import RedirectBackComponent from "../src/components/RedirectBackComponent"; import RedirectBackComponent from "../src/components/RedirectBackComponent";
import ServeStaticDirectoryComponent from "../src/components/ServeStaticDirectoryComponent"; import ServeStaticDirectoryComponent from "../src/components/ServeStaticDirectoryComponent";
import {Express} from "express"; import {Express} from "express";
import packageJson = require('../package.json');
export default function useApp(appSupplier?: (addr: string, port: number) => TestApp) { export default function useApp(appSupplier?: (addr: string, port: number) => TestApp): void {
let app: Application; let app: Application;
beforeAll(async (done) => { beforeAll(async (done) => {
@ -30,8 +30,21 @@ export default function useApp(appSupplier?: (addr: string, port: number) => Tes
}); });
afterAll(async (done) => { afterAll(async (done) => {
const errors = [];
try {
await app.stop(); await app.stop();
} catch (e) {
errors.push(e);
}
try {
await teardownMailServer(); await teardownMailServer();
} catch (e) {
errors.push(e);
}
if (errors.length > 0) throw errors;
done(); done();
}); });
} }
@ -41,23 +54,23 @@ export class TestApp extends Application {
private readonly port: number; private readonly port: number;
private expressAppComponent?: ExpressAppComponent; private expressAppComponent?: ExpressAppComponent;
constructor(addr: string, port: number) { public constructor(addr: string, port: number) {
super(require('../package.json').version, true); super(packageJson.version, true);
this.addr = addr; this.addr = addr;
this.port = port; this.port = port;
} }
protected getMigrations(): Type<Migration>[] { protected getMigrations(): MigrationType<Migration>[] {
return MIGRATIONS; return MIGRATIONS;
} }
protected async init(): Promise<void> { protected async init(): Promise<void> {
this.registerComponents(); this.registerComponents();
this.registerWebSocketListeners(); this.registerWebSocketListeners?.();
this.registerControllers(); this.registerControllers?.();
} }
protected registerComponents() { protected registerComponents(): void {
this.expressAppComponent = new ExpressAppComponent(this.addr, this.port); this.expressAppComponent = new ExpressAppComponent(this.addr, this.port);
const redisComponent = new RedisComponent(); const redisComponent = new RedisComponent();
const mysqlComponent = new MysqlComponent(); const mysqlComponent = new MysqlComponent();
@ -84,7 +97,7 @@ export class TestApp extends Application {
// Auth // Auth
this.use(new AuthComponent(new class extends AuthGuard<MagicLink> { this.use(new AuthComponent(new class extends AuthGuard<MagicLink> {
public async getProofForSession(session: Express.Session): Promise<MagicLink | null> { public async getProofForSession(session: Express.Session): Promise<MagicLink | null> {
return await MagicLink.bySessionID(session.id, ['login', 'register']); return await MagicLink.bySessionId(session.id, ['login', 'register']);
} }
})); }));
@ -92,15 +105,11 @@ export class TestApp extends Application {
this.use(new FormHelperComponent()); this.use(new FormHelperComponent());
} }
protected registerWebSocketListeners() { protected registerWebSocketListeners?(): void;
}
protected registerControllers() { protected registerControllers?(): void;
}
public getExpressApp(): Express { public getExpressApp(): Express {
return this.expressAppComponent!.getExpressApp(); return this.as(ExpressAppComponent).getExpressApp();
} }
} }
export const DEFAULT_ADDR = 'http://localhost:8966';

View File

@ -1,19 +1,19 @@
const MailDev = require("maildev"); import MailDev from "maildev";
export const MAIL_SERVER = new MailDev({ export const MAIL_SERVER = new MailDev({
ip: 'localhost', ip: 'localhost',
}); });
export async function setupMailServer() { export async function setupMailServer(): Promise<void> {
await new Promise((resolve, reject) => MAIL_SERVER.listen((err: Error) => { await new Promise((resolve, reject) => MAIL_SERVER.listen((err?: Error) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
})); }));
}; }
export async function teardownMailServer() { export async function teardownMailServer(): Promise<void> {
await new Promise((resolve, reject) => MAIL_SERVER.close((err: Error) => { await new Promise((resolve, reject) => MAIL_SERVER.close((err?: Error) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
})); }));
}; }

216
test/types/maildev.d.ts vendored Normal file
View File

@ -0,0 +1,216 @@
// Type definitions for maildev 1.0.0-rc3
// Project: https://github.com/djfarrelly/maildev
// Definitions by: Cyril Schumacher <https://github.com/cyrilschumacher>
// Zak Barbuto <https://github.com/zbarbuto>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
/// <reference types="node"/>
declare module 'maildev' {
import fs = require("fs");
/**
* Interface for {@link MailDev}.
*/
export default class MailDev {
/**
* Constructor.
*
* @public
* @param {MailDevOptions} options The options.
*/
public constructor(options: MailDevOptions);
/**
* Deletes a given email by identifier.
*
* @public
* @param {string} id The email identifier.
* @param {Function} callback The error callback.
*/
public deleteEmail(id: string, callback?: (error: Error) => void): void;
/**
* Deletes all email and their attachments.
*
* @public
* @param {Function} callback The error callback.
*/
public deleteAllEmail(callback?: (error: Error) => void): void;
/**
* Stops the SMTP server.
*
* @public
* @param {Function} callback The error callback.
*/
public close(callback?: (error: Error) => void): void;
/**
* Accepts e-mail identifier, returns email object.
*
* @public
* @param {string} id The e-mail identifier.
* @param {Function} callback The error callback.
*/
public getEmail(id: string, callback?: (error: Error) => void): void;
/**
* Returns a readable stream of the raw e-mail.
*
* @public
* @param {string} id The e-mail identifier.
*/
public getRawEmail(id: string, callback?: (error: Error, readStream: fs.ReadStream) => void): void;
/**
* Returns array of all e-mail.
* @public
*/
public getAllEmail(done: (error: Error, emails: Array<Record<string, unknown>>) => void): void;
/**
* Starts the SMTP server.
*
* @public
* @param {Function} callback The error callback.
*/
public listen(callback?: (error: Error) => void): void;
/**
* Event called when a new e-mail is received. Callback receives single mail object.
*
* @public
* @param {string} eventName The event name.
* @param {Function} email The email.
*/
public on(eventName: string, callback: (email: Record<string, unknown>) => void): void;
/**
* Relay the e-mail.
*
* @param {string} idOrMailObject The identifier or mail object.
* @param {Function} done The callback.
*/
public relayMail(idOrMailObject: string, done: (error: Error) => void): void;
}
/**
* Interface for {@link MailDev} options.
*/
export interface MailDevOptions {
/**
* IP Address to bind SMTP service to', '0.0.0.0'
*
* @type {string}
*/
ip?: string;
/**
* SMTP host for outgoing emails
*
* @type {string}
*/
outgoingHost?: string;
/**
* SMTP password for outgoing emails
*
* @type {string}
*/
outgoingPass?: string;
/**
* SMTP port for outgoing emails.
*
* @type {number}
*/
outgoingPort?: number;
/**
* SMTP user for outgoing emails
*
* @type {string}
*/
outgoingUser?: string;
/**
* Use SMTP SSL for outgoing emails
*
* @type {boolean}
*/
outgoingSecure?: boolean;
/**
* SMTP port to catch emails.
*
* @type {number}
*/
smtp?: number;
/**
* Port to use for web UI
*
* @type {number}
*/
web?: number;
/**
* IP Address to bind HTTP service to
*
* @type {string}
*/
webIp?: string;
/**
* Do not start web UI
*
* @type {boolean}
*/
disableWeb?: boolean;
/**
* Do not output console.log messages
*
* @type {boolean}
*/
silent?: boolean;
/**
* HTTP user for GUI
*
* @type {string}
*/
webUser?: string;
/**
* HTTP password for GUI
*
* @type {string}
*/
webPass?: string;
/**
* Open the Web GUI after startup
*
* @type {boolean}
*/
open?: boolean;
}
/**
* Interface for mail.
*/
export interface Mail {
/**
* Identifier.
*/
id?: string;
/**
* Client.
*/
envelope?: Record<string, unknown>;
}
}

View File

@ -1 +1 @@
{{ getCSRFToken() }} {{ getCsrfToken() }}

View File

@ -6,8 +6,6 @@
"stripInternal": true, "stripInternal": true,
"strict": true, "strict": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"moduleResolution": "Node", "moduleResolution": "Node",

14
tsconfig.test.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"typeRoots": [
"node_modules/@types",
"src/types",
"test/types"
]
},
"include": [
"src/types/**/*",
"test/**/*"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -29,18 +29,18 @@
<a href="/auth" class="button transparent">Go back</a> <a href="/auth" class="button transparent">Go back</a>
<button type="submit" class="primary">Register</button> <button type="submit" class="primary">Register</button>
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
</form> </form>
{% else %} {% else %}
<form action="{{ action }}" method="POST" id="login-form"> <form action="{{ action }}" method="POST" id="login-form">
<h2>Log in or register</h2> <h2>Log in or register</h2>
{# {{ macros.message('info', 'If we don\'t find your email address in our database, you will be able to register.', false, true) }}#} {# {{ macros.message('info', 'If we don\'t find your email address in our database, you will be able to register.', false, true) }} #}
<div class="input-field"> <div class="input-field">
{{ macros.field(_locals, 'email', 'email', query.email or '', 'Your email address', "If we don't find your email address in our database, you will be able to register.", 'required') }} {{ macros.field(_locals, 'email', 'email', query.email or '', 'Your email address', "If we don't find your email address in our database, you will be able to register.", 'required') }}
</div> </div>
<button type="submit">Authenticate</button> <button type="submit">Authenticate</button>
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
</form> </form>
{% endif %} {% endif %}
</div> </div>

View File

@ -28,14 +28,14 @@
<form action="{{ route('approve-account') }}" method="POST"> <form action="{{ route('approve-account') }}" method="POST">
<input type="hidden" name="user_id" value="{{ user.id }}"> <input type="hidden" name="user_id" value="{{ user.id }}">
<button class="success"><i data-feather="check"></i> Approve</button> <button class="success"><i data-feather="check"></i> Approve</button>
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
</form> </form>
<form action="{{ route('reject-account') }}" method="POST" <form action="{{ route('reject-account') }}" method="POST"
data-confirm="This will irrevocably delete the {{ user.mainEmail.getOrFail().email | default(user.name | default(user.id)) }} account."> data-confirm="This will irrevocably delete the {{ user.mainEmail.getOrFail().email | default(user.name | default(user.id)) }} account.">
<input type="hidden" name="user_id" value="{{ user.id }}"> <input type="hidden" name="user_id" value="{{ user.id }}">
<button class="danger"><i data-feather="check"></i> Reject</button> <button class="danger"><i data-feather="check"></i> Reject</button>
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
</form> </form>
</div> </div>
</td> </td>

View File

@ -28,8 +28,8 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% macro csrf(getCSRFToken) %} {% macro csrf(getCsrfToken) %}
<input type="hidden" name="csrf" value="{{ getCSRFToken() }}"> <input type="hidden" name="csrf" value="{{ getCsrfToken() }}">
{% endmacro %} {% endmacro %}
{% macro field(_locals, type, name, value, placeholder, hint, validation_attributes='', extraData='', icon=null) %} {% macro field(_locals, type, name, value, placeholder, hint, validation_attributes='', extraData='', icon=null) %}