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": [
"@typescript-eslint"
],
"parserOptions": {
"project": [
"./tsconfig.json",
"./tsconfig.test.json"
]
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"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": [
"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 = {
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json',
}
},
transform: {
"^.+\\.ts$": "ts-jest"
},
@ -10,5 +15,5 @@ module.exports = {
testMatch: [
'**/test/**/*.test.ts'
],
testEnvironment: 'node'
testEnvironment: 'node',
};

View File

@ -6,7 +6,7 @@ import WebSocketListener from "./WebSocketListener";
import ApplicationComponent from "./ApplicationComponent";
import Controller from "./Controller";
import MysqlConnectionManager from "./db/MysqlConnectionManager";
import Migration from "./db/Migration";
import Migration, {MigrationType} from "./db/Migration";
import {Type} from "./Utils";
import LogRequestsComponent from "./components/LogRequestsComponent";
import {ValidationBag} from "./db/Validator";
@ -19,7 +19,7 @@ import RedisComponent from "./components/RedisComponent";
import Extendable from "./Extendable";
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 ignoreCommandLine: boolean;
private readonly controllers: Controller[] = [];
@ -34,11 +34,11 @@ export default abstract class Application implements Extendable<ApplicationCompo
this.ignoreCommandLine = ignoreCommandLine;
}
protected abstract getMigrations(): Type<Migration>[];
protected abstract getMigrations(): MigrationType<Migration>[];
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) {
thing.setApp(this);
this.controllers.push(thing);
@ -88,7 +88,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
app.use(handleRouter);
// 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 (err instanceof ValidationBag) {
@ -104,17 +104,18 @@ export default abstract class Application implements Extendable<ApplicationCompo
},
text: () => {
res.status(401);
res.send('Error: ' + err.getMessages())
res.send('Error: ' + err.getMessages());
},
html: () => {
req.flash('validation', err.getMessages());
req.flash('validation', err.getMessages().toString());
res.redirectBack();
},
});
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;
@ -123,7 +124,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
} else if (err instanceof TemplateError && err.cause instanceof HttpError) {
httpError = err.cause;
} else {
httpError = new ServerError('Internal server error.', err);
httpError = new ServerError('Internal server error.', err instanceof Error ? err : undefined);
}
res.status(httpError.errorCode);
@ -133,7 +134,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
error_code: httpError.errorCode,
error_message: httpError.message,
error_instructions: httpError.instructions,
error_id: errorID,
error_id: errorId,
});
},
json: () => {
@ -142,12 +143,12 @@ export default abstract class Application implements Extendable<ApplicationCompo
code: httpError.errorCode,
message: httpError.message,
instructions: httpError.instructions,
error_id: errorID,
error_id: errorId,
});
},
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;
}
public getCache(): CacheProvider | undefined {
return this.cacheProvider;
public getCache(): CacheProvider | null {
return this.cacheProvider || null;
}
public as<C extends ApplicationComponent>(type: Type<C>): C {
const component = this.components.find(component => component.constructor === type);
if (!component) throw new Error(`This app doesn't have a ${type.name} component.`);
return component as C;
public as<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): C {
const module = this.components.find(component => component.constructor === type) ||
Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
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 {
const component = this.components.find(component => component.constructor === type);
return component ? component as C : null;
public asOptional<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): C | null {
const module = this.components.find(component => component.constructor === type) ||
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 Logger from "./Logger";
import {sleep, Type} from "./Utils";
import {sleep} from "./Utils";
import Application from "./Application";
import config from "config";
import SecurityError from "./SecurityError";
import Middleware from "./Middleware";
import Middleware, {MiddlewareType} from "./Middleware";
export default abstract class ApplicationComponent {
private currentRouter?: Router;
@ -28,16 +28,16 @@ export default abstract class ApplicationComponent {
err = null;
} catch (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);
}
} while (err);
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 {
await new Promise((resolve, reject) => fn.call(thing, (err: any) => {
await new Promise((resolve, reject) => fn((err?: Error | null) => {
if (err) reject(err);
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') {
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().');
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 {
this.currentRouter = router || undefined;
}
@ -80,7 +76,7 @@ export default abstract class ApplicationComponent {
return this.app;
}
public setApp(app: Application) {
public setApp(app: Application): void {
this.app = app;
}
}

View File

@ -11,4 +11,4 @@ export default interface CacheProvider {
* @param ttl in ms
*/
remember(key: string, value: string, ttl: number): Promise<void>;
}
}

View File

@ -6,14 +6,18 @@ import Validator, {ValidationBag} from "./db/Validator";
import FileUploadMiddleware from "./FileUploadMiddleware";
import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring";
import Middleware from "./Middleware";
import {Type} from "./Utils";
import Middleware, {MiddlewareType} from "./Middleware";
import Application from "./Application";
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];
if (path === undefined) throw new Error(`Unknown route for name ${route}.`);
@ -29,10 +33,8 @@ export default abstract class Controller {
}
path = path.replace(/\/+/g, '/');
} else {
for (const key in params) {
if (params.hasOwnProperty(key)) {
path = path.replace(new RegExp(`:${key}\\??`), params[key]);
}
for (const key of Object.keys(params)) {
path = path.replace(new RegExp(`:${key}\\??`), params[key]);
}
}
@ -64,10 +66,7 @@ export default abstract class Controller {
public abstract routes(): void;
public setupRoutes(): {
mainRouter: Router,
fileUploadFormRouter: Router
} {
public setupRoutes(): { mainRouter: Router, fileUploadFormRouter: Router } {
this.routes();
return {
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);
}
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);
}
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);
}
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);
}
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);
}
@ -100,7 +119,7 @@ export default abstract class Controller {
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (Type<Middleware>)[]
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.registerRoutes(path, handler, routeName);
for (const middleware of middlewares) {
@ -152,18 +171,19 @@ 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();
for (const p in validationMap) {
if (validationMap.hasOwnProperty(p)) {
try {
await validationMap[p].execute(p, body[p], false);
} catch (e) {
if (e instanceof ValidationBag) {
bag.addBag(e);
} else throw e;
}
for (const p of Object.keys(validationMap)) {
try {
await validationMap[p].execute(p, body[p], false);
} catch (e) {
if (e instanceof ValidationBag) {
bag.addBag(e);
} else throw e;
}
}
@ -175,7 +195,7 @@ export default abstract class Controller {
return this.app;
}
public setApp(app: Application) {
public setApp(app: Application): void {
this.app = app;
}
}

View File

@ -4,4 +4,4 @@ export default interface Extendable<ComponentClass> {
as<C extends ComponentClass>(type: Type<C>): C;
asOptional<C extends ComponentClass>(type: Type<C>): C | null;
}
}

View File

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

View File

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

View File

@ -1,17 +1,31 @@
import config from "config";
import {v4 as uuid} from "uuid";
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 {
private static logLevel: LogLevelKeys = <LogLevelKeys>config.get<string>('log.level');
private static dbLogLevel: LogLevelKeys = <LogLevelKeys>config.get<string>('log.db_level');
private static logLevel: LogLevel = LogLevel[<LogLevelKeys>config.get<string>('log.level')];
private static dbLogLevel: LogLevel = LogLevel[<LogLevelKeys>config.get<string>('log.db_level')];
private static verboseMode: boolean = config.get<boolean>('log.verbose');
public static verbose() {
public static verbose(): void {
this.verboseMode = true;
this.logLevel = <LogLevelKeys>LogLevel[LogLevel[this.logLevel] + 1] || this.logLevel;
this.dbLogLevel = <LogLevelKeys>LogLevel[LogLevel[this.dbLogLevel] + 1] || this.dbLogLevel;
if (LogLevel[this.logLevel + 1]) this.logLevel++;
if (LogLevel[this.dbLogLevel + 1]) this.dbLogLevel++;
Logger.info('Verbose mode');
}
@ -19,46 +33,45 @@ export default class Logger {
return this.verboseMode;
}
public static silentError(error: Error, ...message: any[]): string {
return this.log('ERROR', message, error, true) || '';
public static silentError(error: Error, ...message: unknown[]): string {
return this.log(LogLevel.ERROR, message, error, true) || '';
}
public static error(error: Error, ...message: any[]): string {
return this.log('ERROR', message, error) || '';
public static error(error: Error, ...message: unknown[]): string {
return this.log(LogLevel.ERROR, message, error) || '';
}
public static warn(...message: any[]) {
this.log('WARN', message);
public static warn(...message: unknown[]): void {
this.log(LogLevel.WARN, message);
}
public static info(...message: any[]) {
this.log('INFO', message);
public static info(...message: unknown[]): void {
this.log(LogLevel.INFO, message);
}
public static debug(...message: any[]) {
this.log('DEBUG', message);
public static debug(...message: unknown[]): void {
this.log(LogLevel.DEBUG, message);
}
public static dev(...message: any[]) {
this.log('DEV', message);
public static dev(...message: unknown[]): void {
this.log(LogLevel.DEV, message);
}
private static log(level: LogLevelKeys, message: any[], error?: Error, silent: boolean = false): string | null {
const levelIndex = LogLevel[level];
if (levelIndex <= LogLevel[this.logLevel]) {
private static log(level: LogLevel, message: unknown[], error?: Error, silent: boolean = false): string | null {
if (level <= this.logLevel) {
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 {
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 => {
if (typeof v === 'string') {
return v;
} else {
return JSON.stringify(v, (key: string, value: any) => {
if (value instanceof Object) {
if (value.type === 'Buffer') {
return JSON.stringify(v, (key: string, value: string | unknown[] | Record<string, unknown>) => {
if (!Array.isArray(value) && value instanceof Object) {
if (value.type === 'Buffer' && typeof value.data === 'string') {
return `Buffer<${Buffer.from(value.data).toString('hex')}>`;
} else if (value !== v) {
return `[object Object]`;
@ -72,65 +85,56 @@ export default class Logger {
}
}).join(' ');
const shouldSaveToDB = levelIndex <= LogLevel[this.dbLogLevel];
const shouldSaveToDB = level <= this.dbLogLevel;
let output = `[${level}] `;
const pad = output.length;
const logID = Buffer.alloc(16);
uuid({}, logID);
let strLogID = bufferToUUID(logID);
if (shouldSaveToDB) output += `${strLogID} - `;
const logId = Buffer.alloc(16);
uuid({}, logId);
const strLogId = bufferToUuid(logId);
if (shouldSaveToDB) output += `${strLogId} - `;
output += computedMsg.replace(/\n/g, '\n' + ' '.repeat(pad));
switch (level) {
case "ERROR":
case LogLevel.ERROR:
if (silent || !error) {
console.error(output);
} else {
console.error(output, error);
}
break;
case "WARN":
case LogLevel.WARN:
console.warn(output);
break;
case "INFO":
case LogLevel.INFO:
console.info(output);
break;
case "DEBUG":
case "DEV":
case LogLevel.DEBUG:
case LogLevel.DEV:
console.debug(output);
break;
}
if (shouldSaveToDB) {
if (shouldSaveToDB && ModelFactory.has(Log)) {
const log = Log.create({});
log.setLevel(level);
log.message = computedMsg;
log.setError(error);
log.setLogID(logID);
log.setLogId(logId);
log.save().catch(err => {
if (!silent && err.message.indexOf('ECONNREFUSED') < 0) {
console.error({save_err: err, error});
}
});
}
return strLogID;
return strLogId;
}
return null;
}
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 Logger from "./Logger";
import Controller from "./Controller";
import {ParsedUrlQueryInput} from "querystring";
export default class Mail {
private static transporter: Transporter;
private static transporter?: Transporter;
private static getTransporter(): Transporter {
if (!this.transporter) throw new MailError('Mail system was not prepared.');
@ -26,8 +27,8 @@ export default class Mail {
pass: config.get('mail.password'),
},
tls: {
rejectUnauthorized: !config.get('mail.allow_invalid_tls')
}
rejectUnauthorized: !config.get('mail.allow_invalid_tls'),
},
});
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')}`);
}
public static end() {
public static end(): void {
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;
const nunjucksResult = nunjucks.render(template, data);
if (textOnly) return nunjucksResult;
@ -60,9 +61,9 @@ export default class Mail {
private readonly template: MailTemplate;
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.data = data;
this.options.subject = this.template.getSubject(data);
@ -97,7 +98,8 @@ export default class Mail {
// Set data
this.data.mail_subject = this.options.subject;
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');
// Log
@ -117,9 +119,9 @@ export default class Mail {
export class MailTemplate {
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.subject = subject;
}
@ -128,13 +130,13 @@ export class MailTemplate {
return this._template;
}
public getSubject(data: any): string {
public getSubject(data: { [p: string]: unknown }): string {
return `${config.get('app.name')} - ${this.subject(data)}`;
}
}
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);
}
}
}

View File

@ -3,12 +3,12 @@ import {MailTemplate} from "./Mail";
export const MAGIC_LINK_MAIL = new MailTemplate(
'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(
'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(

View File

@ -1,6 +1,7 @@
import {RequestHandler} from "express";
import {NextFunction, Request, Response} from "express-serve-static-core";
import Application from "./Application";
import {Type} from "./Utils";
export default abstract class Middleware {
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 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.page = page;
this.perPage = perPage;
@ -21,4 +21,4 @@ export default class Pagination<T extends Model> {
return this.models.length >= this.perPage && this.page * this.perPage < this.totalCount;
}
}
}

View File

@ -5,4 +5,4 @@ export default class SecurityError implements Error {
public constructor(message: string) {
this.message = message;
}
}
}

View File

@ -1,7 +1,7 @@
import {TooManyRequestsHttpError} from "./HttpError";
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.
@ -16,13 +16,21 @@ export default class Throttler {
* @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.
*/
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];
if (!throttle) throttle = this.throttles[action] = new Throttle(max, resetPeriod, holdPeriod, jailPeriod);
throttle.trigger(id);
}
private constructor() {
// Disable constructor
}
}
@ -31,15 +39,13 @@ class Throttle {
private readonly resetPeriod: number;
private readonly holdPeriod: number;
private readonly jailPeriod: number;
private readonly triggers: {
[id: string]: {
count: number,
lastTrigger?: number,
jailed?: number;
}
} = {};
private readonly triggers: Record<string, {
count: number,
lastTrigger?: 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.resetPeriod = resetPeriod;
this.holdPeriod = holdPeriod;
@ -51,12 +57,10 @@ class Throttle {
let trigger = this.triggers[id];
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) {
this.throw((trigger.jailed + this.jailPeriod) - currentDate);
return;
}
if (trigger.jailed && currentDate - trigger.jailed < this.jailPeriod)
return this.throw(trigger.jailed + this.jailPeriod - currentDate);
if (trigger.lastTrigger) {
let timeDiff = currentDate - trigger.lastTrigger;
@ -71,12 +75,11 @@ class Throttle {
if (trigger.count > this.max) {
trigger.jailed = currentDate;
this.throw((trigger.jailed + this.jailPeriod) - currentDate);
return;
return this.throw(trigger.jailed + this.jailPeriod - currentDate);
}
}
protected throw(unjailedIn: number) {
throw new TooManyRequestsHttpError(unjailedIn);
}
}
}

View File

@ -18,7 +18,7 @@ export abstract class WrappingError extends Error {
}
}
get name(): string {
public get name(): string {
return this.constructor.name;
}
}
@ -28,15 +28,15 @@ export function cryptoRandomDictionary(size: number, dictionary: string): string
const output = new Array(size);
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('');
}
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');
let out = '';
let i = 0;
@ -48,12 +48,12 @@ export function bufferToUUID(buffer: Buffer): string {
return out;
}
export function getMethods<T>(obj: T): (string)[] {
let properties = new Set()
let currentObj = obj
export function getMethods<T extends { [p: string]: unknown }>(obj: T): string[] {
const properties = new Set<string>();
let currentObj: T | unknown = obj;
do {
Object.getOwnPropertyNames(currentObj).map(item => properties.add(item))
} while ((currentObj = Object.getPrototypeOf(currentObj)))
// @ts-ignore
return [...properties.keys()].filter(item => typeof obj[item] === 'function')
Object.getOwnPropertyNames(currentObj).map(item => properties.add(item));
currentObj = Object.getPrototypeOf(currentObj);
} while (currentObj);
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 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> {
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) {
this.user = await proof.getResource();
res.locals.user = this.user;
@ -76,7 +76,7 @@ export class RequireAuthMiddleware extends Middleware {
}
// 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}.`);
res.redirect(Controller.route('auth', undefined, {
redirect_uri: req.url,
@ -90,7 +90,7 @@ export class RequireAuthMiddleware extends Middleware {
export class RequireGuestMiddleware extends Middleware {
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();
return;
}

View File

@ -7,14 +7,14 @@ export default abstract class AuthController extends Controller {
return '/auth';
}
public routes() {
public routes(): void {
this.get('/', this.getAuth, 'auth', RequireGuestMiddleware);
this.post('/', this.postAuth, 'auth', RequireGuestMiddleware);
this.get('/check', this.getCheckAuth, 'check_auth');
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');
res.render('auth/auth', {
register_confirm_email: registerEmail.length > 0 ? registerEmail[0] : null,
@ -25,11 +25,11 @@ export default abstract class AuthController extends Controller {
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);
await proof?.revoke();
req.flash('success', 'Successfully logged out.');
res.redirect(req.query.redirect_uri?.toString() || '/');
}
}
}

View File

@ -11,7 +11,7 @@ import config from "config";
export default abstract class AuthGuard<P extends AuthProof<User>> {
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;
}
@ -52,7 +52,7 @@ export default abstract class AuthGuard<P extends AuthProof<User>> {
session: Express.Session,
proof: P,
onLogin?: (user: User) => Promise<void>,
onRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>
onRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
): Promise<User> {
if (!await proof.isValid()) throw new InvalidAuthProofError();
if (!await proof.isAuthorized()) throw new UnauthorizedAuthProofError();
@ -63,27 +63,24 @@ export default abstract class AuthGuard<P extends AuthProof<User>> {
if (!user) {
const callbacks: RegisterCallback[] = [];
await MysqlConnectionManager.wrapTransaction(async connection => {
user = User.create({});
user = await MysqlConnectionManager.wrapTransaction(async connection => {
const user = User.create({});
await user.save(connection, c => callbacks.push(c));
if (onRegister) {
(await onRegister(connection, user)).forEach(c => callbacks.push(c));
}
return user;
});
for (const callback of callbacks) {
await callback();
}
if (user) {
if (!user!.isApproved()) {
await new Mail(PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: user!.name,
link: config.get<string>('base_url') + Controller.route('accounts-approval'),
}).send(config.get<string>('app.contact_email'));
}
} else {
throw new Error('Unable to register user.');
if (!user.isApproved()) {
await new Mail(PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: (await user.mainEmail.get())?.getOrFail('email'),
link: config.get<string>('base_url') + Controller.route('accounts-approval'),
}).send(config.get<string>('app.contact_email'));
}
}
@ -102,28 +99,25 @@ export default abstract class AuthGuard<P extends AuthProof<User>> {
}
export class AuthError extends Error {
constructor(message: string) {
super(message);
}
}
export class AuthProofError extends AuthError {
}
export class InvalidAuthProofError extends AuthProofError {
constructor() {
public constructor() {
super('Invalid auth proof.');
}
}
export class UnauthorizedAuthProofError extends AuthProofError {
constructor() {
public constructor() {
super('Unauthorized auth proof.');
}
}
export class PendingApprovalAuthError extends AuthError {
constructor() {
public constructor() {
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
* 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()}
* both return {@code true}.
* Any auth system should consider this auth proof valid if and only if both {@code isValid()} and
* {@code isAuthorized()} both return {@code true}.
*
* @type <R> The resource type this AuthProof authorizes.
*/
@ -34,7 +34,8 @@ export default interface AuthProof<R> {
* - {@code isAuthorized} returns {@code false}
* - 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>;
}
}

View File

@ -3,12 +3,12 @@ import Controller from "../Controller";
import Mail from "../Mail";
export default class MailController extends Controller {
routes(): void {
public routes(): void {
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'];
response.send(Mail.parse(`mails/${template}.mjml.njk`, request.query, false));
}
}
}

View File

@ -15,38 +15,40 @@ import User from "../models/User";
export default abstract class MagicLinkAuthController extends AuthController {
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.isValid()) throw new InvalidMagicLink();
// Auth
try {
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(req.session!, magicLink, undefined, async (connection, user) => {
const callbacks: RegisterCallback[] = [];
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
session, magicLink, undefined, async (connection, user) => {
const callbacks: RegisterCallback[] = [];
const userEmail = UserEmail.create({
user_id: user.id,
email: magicLink.getEmail(),
const userEmail = UserEmail.create({
user_id: user.id,
email: magicLink.getOrFail('email'),
});
await userEmail.save(connection, c => callbacks.push(c));
user.main_email_id = userEmail.id;
await user.save(connection, c => callbacks.push(c));
return callbacks;
});
await userEmail.save(connection, c => callbacks.push(c));
user.main_email_id = userEmail.id;
await user.save(connection, c => callbacks.push(c));
return callbacks;
});
} catch (e) {
if (e instanceof PendingApprovalAuthError) {
res.format({
json: () => {
res.json({
'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: () => {
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
res.redirect('/');
}
},
});
return null;
} else {
@ -65,7 +67,8 @@ export default abstract class MagicLinkAuthController extends AuthController {
}
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()) {
res.redirect(Controller.route('magic_link_lobby', 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);
}
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;
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)
const geo = geoip.lookup(req.ip);
await MagicLinkController.sendMagicLink(
req.sessionID!,
req.getSession().id,
isRegistration ? this.registerMagicLinkActionType : this.loginMagicLinkActionType,
Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
@ -107,7 +110,7 @@ export default abstract class MagicLinkAuthController extends AuthController {
type: isRegistration ? 'register' : 'login',
ip: req.ip,
geo: geo ? `${geo.city}, ${geo.country}` : 'Unknown location',
}
},
);
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
*/
protected async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const magicLink = await MagicLink.bySessionID(req.sessionID!, [this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
protected async getCheckAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const magicLink = await MagicLink.bySessionId(req.getSession().id,
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
if (!magicLink) {
res.format({
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: () => {
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 {
constructor() {
public constructor() {
super(`This magic link doesn't belong to this session.`);
}
}
export class UnauthorizedMagicLink extends AuthError {
constructor() {
public constructor() {
super(`This magic link is unauthorized.`);
}
}
export class InvalidMagicLink extends AuthError {
constructor() {
public constructor() {
super(`This magic link is invalid.`);
}
}
}

View File

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

View File

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

View File

@ -16,4 +16,4 @@ export default class AddApprovedFieldToUsersTable extends Migration {
public registerModels(): void {
ModelFactory.get(User).addComponent(UserApprovedComponent);
}
}
}

View File

@ -27,4 +27,4 @@ export default class CreateMagicLinksTable extends Migration {
ModelFactory.register(MagicLink);
}
}
}

View File

@ -10,6 +10,4 @@ export default class DropNameFromUsers extends Migration {
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
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
SET ue.main = true`, connection)
SET ue.main = true`, connection);
await this.query(`ALTER TABLE users
DROP FOREIGN KEY main_user_email_fk,
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";
export default class MagicLink extends Model implements AuthProof<User> {
public static async bySessionID(sessionID: string, actionType?: string | string[]): Promise<MagicLink | null> {
let query = this.select().where('session_id', sessionID);
public static async bySessionId(sessionId: string, actionType?: string | string[]): Promise<MagicLink | null> {
let query = this.select().where('session_id', sessionId);
if (actionType !== undefined) {
if (typeof actionType === 'string') {
query = query.where('action_type', actionType);
@ -26,10 +26,10 @@ export default class MagicLink extends Model implements AuthProof<User> {
}
public readonly id?: number = undefined;
private session_id?: string = undefined;
public readonly session_id?: string = undefined;
private email?: string = undefined;
private token?: string = undefined;
private action_type?: string = undefined;
public readonly action_type?: string = undefined;
private original_url?: string = undefined;
private generated_at?: Date = undefined;
private authorized: boolean = false;
@ -46,9 +46,9 @@ export default class MagicLink extends Model implements AuthProof<User> {
public async getResource(): Promise<User | null> {
const email = await UserEmail.select()
.with('user')
.where('email', await this.getEmail())
.where('email', await this.getOrFail('email'))
.first();
return email ? email.user.get() : null;
return email ? await email.user.get() : null;
}
public async revoke(): Promise<void> {
@ -61,10 +61,10 @@ export default class MagicLink extends Model implements AuthProof<User> {
}
public async isAuthorized(): Promise<boolean> {
return this.authorized!;
return this.authorized;
}
public authorize() {
public authorize(): void {
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.
*/
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
if (new Date().getTime() - this.generated_at.getTime() > MagicLink.validityPeriod()) return 'This token has expired.'; // Token has expired
if (!await argon2.verify(this.token, tokenGuess)) return 'This token is invalid.';
// There is no token
if (this.token === undefined || this.generated_at === undefined)
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;
}
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 {
if (!this.generated_at) return new Date();

View File

@ -8,7 +8,8 @@ import UserApprovedComponent from "./UserApprovedComponent";
export default class User extends Model {
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;
@ -19,7 +20,7 @@ export default class User extends Model {
public readonly emails = new ManyModelRelation(this, UserEmail, {
localKey: 'id',
foreignKey: 'user_id'
foreignKey: 'user_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> {
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, {
localKey: 'user_id',
foreignKey: 'id'
foreignKey: 'id',
});
protected init(): void {

View File

@ -6,6 +6,11 @@ import {ForbiddenHttpError} from "../HttpError";
import Logger from "../Logger";
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> {
this.checkSecurityConfigField('gitlab_webhook_token');
}
@ -13,7 +18,8 @@ export default class AutoUpdateComponent extends ApplicationComponent {
public async init(router: Router): Promise<void> {
router.post('/update/push.json', (req, res) => {
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);
@ -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);
try {
Logger.info('Starting auto update...');
// Fetch
await this.runCommand(`git pull`);
await AutoUpdateComponent.runCommand(`git pull`);
// Install new dependencies
await this.runCommand(`yarn install --production=false`);
await AutoUpdateComponent.runCommand(`yarn install --production=false`);
// Process assets
await this.runCommand(`yarn dist`);
await AutoUpdateComponent.runCommand(`yarn dist`);
// Stop app
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.');
}
}
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 {
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') {
session.csrf = crypto.randomBytes(64).toString('base64');
}
return session.csrf;
}
public static addExcluder(excluder: (req: Request) => boolean) {
public static addExcluder(excluder: (req: Request) => boolean): void {
this.excluders.push(excluder);
}
@ -24,22 +24,19 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
if (excluder(req)) return next();
}
if (!req.session) {
throw new Error('Session is unavailable.');
}
res.locals.getCSRFToken = () => {
return CsrfProtectionComponent.getCSRFToken(req.session!);
const session = req.getSession();
res.locals.getCsrfToken = () => {
return CsrfProtectionComponent.getCsrfToken(session);
};
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
try {
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.`));
} else if (req.body.csrf === undefined) {
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.`));
}
}
@ -53,20 +50,20 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
}
class InvalidCsrfTokenError extends BadRequestError {
constructor(url: string, details: string, cause?: Error) {
public constructor(url: string, details: string, cause?: Error) {
super(
`Invalid CSRF token`,
`${details} We can't process this request. Please try again.`,
url,
cause
cause,
);
}
get name(): string {
public get name(): string {
return 'Invalid CSRF Token';
}
get errorCode(): number {
public get errorCode(): number {
return 401;
}
}

View File

@ -31,7 +31,7 @@ export default class ExpressAppComponent extends ApplicationComponent {
public async init(router: Router): Promise<void> {
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({
extended: true,
@ -42,7 +42,7 @@ export default class ExpressAppComponent extends ApplicationComponent {
router.use((req, res, next) => {
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);
if (!middleware) throw new Error('Middleware ' + type.name + ' not present in this request.');
return middleware as M;
@ -52,8 +52,9 @@ export default class ExpressAppComponent extends ApplicationComponent {
}
public async stop(): Promise<void> {
if (this.server) {
await this.close('Webserver', this.server, this.server.close);
const server = this.server;
if (server) {
await this.close('Webserver', callback => server.close(callback));
}
}
@ -63,6 +64,7 @@ export default class ExpressAppComponent extends ApplicationComponent {
}
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 {
public async init(router: Router): Promise<void> {
router.use((req, res, next) => {
if (!req.session) {
throw new Error('Session is unavailable.');
}
let _validation: any = null;
let _validation: unknown | null;
res.locals.validation = () => {
if (!_validation) {
const v = req.flash('validation');
@ -16,9 +12,9 @@ export default class FormHelperComponent extends ApplicationComponent {
}
return _validation;
}
};
let _previousFormData: any = null;
let _previousFormData: unknown | null = null;
res.locals.previousFormData = () => {
if (!_previousFormData) {
const v = req.flash('previousFormData');
@ -39,4 +35,4 @@ export default class FormHelperComponent extends ApplicationComponent {
next();
});
}
}
}

View File

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

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());
}
@ -23,4 +23,4 @@ export default class MailComponent extends ApplicationComponent {
Mail.end();
}
}
}

View File

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

View File

@ -3,7 +3,7 @@ import {Express} from "express";
import MysqlConnectionManager from "../db/MysqlConnectionManager";
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());
}
@ -15,4 +15,4 @@ export default class MysqlComponent extends ApplicationComponent {
return MysqlConnectionManager.isReady();
}
}
}

View File

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

View File

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

View File

@ -13,11 +13,11 @@ export default class RedisComponent extends ApplicationComponent implements Cach
private redisClient?: RedisClient;
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'), {
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.');
});
this.store = new RedisStore({
@ -27,8 +27,9 @@ export default class RedisComponent extends ApplicationComponent implements Cach
}
public async stop(): Promise<void> {
if (this.redisClient) {
await this.close('Redis connection', this.redisClient, this.redisClient.quit);
const redisClient = this.redisClient;
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> {
return new Promise<string | null>((resolve, reject) => {
return await new Promise<string | null>((resolve, reject) => {
if (!this.redisClient) {
reject(`Redis store was not initialized.`);
return;
@ -53,7 +54,7 @@ export default class RedisComponent extends ApplicationComponent implements Cach
reject(err);
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> {
return new Promise<void>((resolve, reject) => {
return await new Promise<void>((resolve, reject) => {
if (!this.redisClient) {
reject(`Redis store was not initialized.`);
return;
@ -81,7 +82,7 @@ export default class RedisComponent extends ApplicationComponent implements Cach
}
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) {
reject(`Redis store was not initialized.`);
return;
@ -96,4 +97,4 @@ export default class RedisComponent extends ApplicationComponent implements Cach
});
});
}
}
}

View File

@ -7,21 +7,18 @@ export default class ServeStaticDirectoryComponent extends ApplicationComponent
private readonly root: string;
private readonly path?: PathParams;
constructor(root: string, routePath?: PathParams) {
public constructor(root: string, routePath?: PathParams) {
super();
this.root = path.join(__dirname, '../../../', root);
this.path = routePath;
}
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}));
} else {
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;
}
public async checkSecuritySettings(): Promise<void> {
this.checkSecurityConfigField('session.secret');
if (!config.get<boolean>('session.cookie.secure')) {
@ -39,17 +38,18 @@ export default class SessionComponent extends ApplicationComponent {
router.use(flash());
router.use((req, res, next) => {
if (!req.session) {
throw new Error('Session is unavailable.');
}
req.getSession = () => {
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 = {};
res.locals.flash = (key?: string) => {
const _flash: FlashStorage = {};
res.locals.flash = (key?: string): FlashMessages | unknown[] => {
if (key !== undefined) {
if (_flash[key] === undefined) _flash[key] = req.flash(key) || null;
return _flash[key];
if (_flash[key] === undefined) _flash[key] = req.flash(key);
return _flash[key] || [];
}
if (_flash._messages === undefined) {
@ -65,4 +65,19 @@ export default class SessionComponent extends ApplicationComponent {
next();
});
}
}
}
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 {
private wss?: WebSocket.Server;
constructor(
public constructor(
private readonly application: Application,
private readonly expressAppComponent: ExpressAppComponent,
private readonly storeComponent: RedisComponent,
@ -23,8 +23,8 @@ export default class WebSocketServerComponent extends ApplicationComponent {
super();
}
public async start(app: Express): Promise<void> {
const listeners: { [p: string]: WebSocketListener<any> } = this.application.getWebSocketListeners();
public async start(_app: Express): Promise<void> {
const listeners: { [p: string]: WebSocketListener<Application> } = this.application.getWebSocketListeners();
this.wss = new WebSocketServer({
server: this.expressAppComponent.getServer(),
}, () => {
@ -78,8 +78,9 @@ export default class WebSocketServerComponent extends ApplicationComponent {
}
public async stop(): Promise<void> {
if (this.wss) {
await this.close('WebSocket server', this.wss, this.wss.close);
const wss = this.wss;
if (wss) {
await this.close('WebSocket server', callback => wss.close(callback));
}
}
}
}

View File

@ -1,5 +1,6 @@
import {Connection} from "mysql";
import MysqlConnectionManager from "./MysqlConnectionManager";
import {Type} from "../Utils";
export default abstract class Migration {
public readonly version: number;
@ -16,9 +17,13 @@ export default abstract class Migration {
public abstract async rollback(connection: Connection): Promise<void>;
public abstract registerModels(): void;
public registerModels?(): void;
protected async query(queryString: string, connection: Connection): Promise<void> {
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 {Connection} from "mysql";
import ModelComponent from "./ModelComponent";
import {Type} from "../Utils";
import ModelFactory from "./ModelFactory";
import ModelFactory, {PrimaryKeyValue} from "./ModelFactory";
import ModelRelation from "./ModelRelation";
import ModelQuery, {ModelQueryResult, SelectFields} from "./ModelQuery";
import ModelQuery, {ModelFieldData, ModelQueryResult, SelectFields} from "./ModelQuery";
import {Request} from "express";
import Extendable from "../Extendable";
export default abstract class Model implements Extendable<ModelComponent<Model>> {
public static get table(): string {
return this.name
.replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase())
.replace(/^_/, '')
+ 's';
const single = this.name
.replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase())
.replace(/^_/, '');
return single + 's';
}
public static getPrimaryKeyFields(): string[] {
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);
}
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);
}
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);
}
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();
}
public static async getById<T extends Model>(this: ModelType<T>, ...id: any): Promise<T | null> {
return ModelFactory.get(this).getById(...id);
public static async getById<M extends Model>(this: ModelType<M>, ...id: PrimaryKeyValue[]): Promise<M | null> {
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>> {
return ModelFactory.get(this).paginate(request, perPage, query);
public static async paginate<M extends Model>(
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>;
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;
[key: string]: any;
[key: string]: ModelFieldData;
public constructor(factory: ModelFactory<Model>, isNew: boolean) {
if (!factory || !(factory instanceof ModelFactory)) throw new Error('Cannot instantiate model directly.');
public constructor(factory: ModelFactory<never>, isNew: boolean) {
if (!(factory instanceof ModelFactory)) throw new Error('Cannot instantiate model directly.');
this._factory = factory;
this.init();
this.init?.();
this._exists = !isNew;
}
protected abstract init(): void;
protected init?(): void;
protected setValidation<T>(propertyName: keyof this): Validator<T> {
const validator = new Validator<T>();
this._validators[propertyName as string] = validator;
protected setValidation<K extends keyof this>(propertyName: K): Validator<this[K]> {
const validator = new Validator<this[K]>();
this._validators[propertyName] = validator;
return validator;
}
@ -92,10 +98,10 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
return null;
}
public updateWithData(data: any) {
public updateWithData(data: Pick<this, keyof this> | Record<string, unknown>): void {
for (const property of this._properties) {
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 ?
await this.saveTransaction(connection) :
await MysqlConnectionManager.wrapTransaction(async connection => this.saveTransaction(connection));
await MysqlConnectionManager.wrapTransaction(async connection => await this.saveTransaction(connection));
const callback = async () => {
if (needs_full_update) {
const result = await this._factory.select()
.where('id', this.id!)
.limit(1)
.execute(connection);
const query = this._factory.select();
for (const field of this._factory.getPrimaryKeyFields()) {
query.where(field, this[field]);
}
query.limit(1);
const result = await query.execute(connection);
this.updateWithData(result.results[0]);
}
await this.afterSave?.();
};
if (connection) {
postHook!(callback);
if (postHook) {
postHook(callback);
} else {
await callback();
}
@ -142,19 +150,19 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
private async saveTransaction(connection: Connection): Promise<boolean> {
// Before save
await this.beforeSave?.(connection);
if (!this.exists() && this.hasOwnProperty('created_at')) {
if (!this.exists() && this.hasProperty('created_at')) {
this.created_at = new Date();
}
if (this.exists() && this.hasOwnProperty('updated_at')) {
if (this.exists() && this.hasProperty('updated_at')) {
this.updated_at = new Date();
}
const properties = [];
const values = [];
const values: QueryVariable[] = [];
let needsFullUpdate = false;
if (this.exists()) {
const data: any = {};
const data: { [K in keyof this]?: this[K] } = {};
for (const property of this._properties) {
const value = this[property];
@ -170,21 +178,24 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
} else {
const props_holders = [];
for (const property of this._properties) {
const value = this[property];
let value: ModelFieldData = this[property];
if (value === undefined) {
needsFullUpdate = true;
} else {
if (!isQueryVariable(value)) {
value = value.toString();
}
properties.push(property);
props_holders.push('?');
values.push(value);
values.push(value as QueryVariable);
}
}
const fieldNames = properties.map(f => `\`${f}\``).join(', ');
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;
}
@ -192,7 +203,7 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
}
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();
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[]> {
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;
}
private get _properties(): string[] {
private get _properties(): (keyof this & string)[] {
return Object.getOwnPropertyNames(this).filter(p => {
return !p.startsWith('_') &&
typeof this[p] !== 'function' &&
!(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;
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 Validator from "./Validator";
import {getMethods} from "../Utils";
import {ModelFieldData} from "./ModelQuery";
export default abstract class ModelComponent<T extends Model> {
protected readonly _model: T;
private readonly _validators: { [key: string]: Validator<any> } = {};
export default abstract class ModelComponent<M extends Model> {
protected readonly _model: M;
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;
}
public applyToModel(): void {
this.init?.();
const model = this._model as Model;
for (const property of this._properties) {
if (!property.startsWith('_')) {
(this._model as Model)[property] = this[property];
model[property] = this[property];
}
}
for (const method of getMethods(this)) {
// @ts-ignore
if (!method.startsWith('_') &&
['init', 'setValidation'].indexOf(method) < 0 &&
this._model[method] === undefined) {
// @ts-ignore
this._model[method] = this[method];
model[method] === undefined) {
model[method] = this[method];
}
}
}
protected init?(): void;
protected setValidation<V>(propertyName: keyof this): Validator<V> {
const validator = new Validator<V>();
this._validators[propertyName as string] = validator;
protected setValidation<K extends keyof this>(propertyName: K): Validator<this[K]> {
const validator = new Validator<this[K]>();
this._validators[propertyName] = validator;
return validator;
}

View File

@ -3,33 +3,37 @@ import Model, {ModelType} from "./Model";
import ModelQuery, {ModelQueryResult, SelectFields} from "./ModelQuery";
import {Request} from "express";
export default class ModelFactory<T extends Model> {
private static readonly factories: { [modelType: string]: ModelFactory<any> } = {};
export default class ModelFactory<M extends Model> {
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.`);
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> {
const factory = this.factories[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>;
private readonly components: ModelComponentFactory<T>[] = [];
public static has<M extends Model>(modelType: ModelType<M>): boolean {
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;
}
public addComponent(modelComponentFactory: ModelComponentFactory<T>) {
public addComponent(modelComponentFactory: ModelComponentFactory<M>): void {
this.components.push(modelComponentFactory);
}
public create(data: any, isNewModel: boolean): T {
const model = new this.modelType(this, isNewModel);
public create(data: Pick<M, keyof M>, isNewModel: boolean): M {
const model = new this.modelType(this as unknown as ModelFactory<never>, isNewModel);
for (const component of this.components) {
model.addComponent(new component(model));
}
@ -41,41 +45,41 @@ export default class ModelFactory<T extends Model> {
return this.modelType.table;
}
public select(...fields: SelectFields): ModelQuery<T> {
public select(...fields: SelectFields): ModelQuery<M> {
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);
}
public delete(): ModelQuery<T> {
public delete(): ModelQuery<M> {
return ModelQuery.delete(this);
}
public getPrimaryKeyFields(): string[] {
public getPrimaryKeyFields(): (keyof M & string)[] {
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]);
}
public getPrimaryKeyString(modelData: any): string {
public getPrimaryKeyString(modelData: Pick<M, keyof M>): string {
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();
const primaryKeyFields = this.getPrimaryKeyFields();
for (let i = 0; i < primaryKeyFields.length; 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>> {
let page = request.params.page ? parseInt(request.params.page) : 1;
public async paginate(request: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> {
const page = request.params.page ? parseInt(request.params.page) : 1;
if (!query) query = this.select();
if (request.params.sortBy) {
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 Model from "./Model";
import Pagination from "../Pagination";
@ -12,14 +12,10 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return new ModelQuery(QueryType.SELECT, factory, fields.length > 0 ? fields : ['*']);
}
public static update<M extends Model>(factory: ModelFactory<M>, data: {
[key: string]: any
}): ModelQuery<M> {
public static update<M extends Model>(factory: ModelFactory<M>, data: { [K in keyof M]?: M[K] }): ModelQuery<M> {
const fields = [];
for (let key in data) {
if (data.hasOwnProperty(key)) {
fields.push(new UpdateFieldValue(inputToFieldOrValue(key, factory.table), data[key], false));
}
for (const key of Object.keys(data)) {
fields.push(new UpdateFieldValue(inputToFieldOrValue(key, factory.table), data[key], false));
}
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 _sortDirection?: 'ASC' | 'DESC';
private readonly relations: string[] = [];
private readonly subRelations: { [relation: string]: string[] } = {};
private readonly subRelations: { [relation: string]: string[] | undefined } = {};
private _pivot?: string[];
private _union?: ModelQueryUnion;
private _recursiveRelation?: RelationDatabaseProperties;
@ -60,33 +56,57 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return this;
}
public on(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));
public on(
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;
}
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));
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));
return this;
}
private collectWheres(setter: (query: WhereFieldConsumer<M>) => void): (WhereFieldValue | WhereFieldValueGroup)[] {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const query = this;
const wheres: (WhereFieldValue | WhereFieldValueGroup)[] = [];
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));
return this;
},
groupWhere(setter: (query: WhereFieldConsumer<M>) => void, operator: WhereOperator = WhereOperator.AND) {
wheres.push(new WhereFieldValueGroup(query.collectWheres(setter), operator))
groupWhere(
setter: (query: WhereFieldConsumer<M>) => void,
operator: WhereOperator = WhereOperator.AND,
) {
wheres.push(new WhereFieldValueGroup(query.collectWheres(setter), operator));
return this;
}
},
});
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 (parts.length > 1) {
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;
@ -124,7 +144,13 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
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.');
this._union = {
@ -150,7 +176,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
if (this._pivot) this.fields.push(...this._pivot);
// Prevent wildcard and fields from conflicting
let fields = this.fields.map(f => {
const fields = this.fields.map(f => {
const field = f.toString();
if (field.startsWith('(')) return f; // Skip sub-queries
return inputToFieldOrValue(field, this.table);
@ -184,7 +210,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
let orderBy = '';
if (typeof this._sortBy === 'string') {
orderBy = ` ORDER BY ${this._sortBy} ${this._sortDirection!}`;
orderBy = ` ORDER BY ${this._sortBy} ${this._sortDirection}`;
}
const table = `\`${this.table}\``;
@ -193,7 +219,9 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
case QueryType.SELECT:
if (this._recursiveRelation) {
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';
query = `WITH RECURSIVE cte AS (`
@ -228,10 +256,10 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return this.toString(true);
}
public get variables(): any[] {
const variables: any[] = [];
this.fields?.filter(v => v instanceof FieldValue)
.flatMap(v => (<FieldValue>v).variables)
public get variables(): QueryVariable[] {
const variables: QueryVariable[] = [];
this.fields.filter(v => v instanceof FieldValue)
.flatMap(v => (v as FieldValue).variables)
.forEach(v => variables.push(v));
this._where.flatMap(v => this.getVariables(v))
.forEach(v => variables.push(v));
@ -239,7 +267,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
return variables;
}
private getVariables(where: WhereFieldValue | WhereFieldValueGroup): any[] {
private getVariables(where: WhereFieldValue | WhereFieldValueGroup): QueryVariable[] {
return where instanceof WhereFieldValueGroup ?
where.fields.flatMap(v => this.getVariables(v)) :
where.variables;
@ -257,33 +285,34 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
if (this._pivot) models.pivot = [];
// 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) {
relationMap[relation] = [];
}
for (const result of queryResult.results) {
const modelData: any = {};
const modelData: Record<string, unknown> = {};
for (const field of Object.keys(result)) {
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.originalData.push(modelData);
if (this._pivot) {
const pivotData: any = {};
if (this._pivot && models.pivot) {
const pivotData: Record<string, unknown> = {};
for (const field of this._pivot) {
pivotData[field] = result[field.split('.')[1]];
}
models.pivot!.push(pivotData);
models.pivot.push(pivotData);
}
// Eager loading init map
for (const relation of this.relations) {
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.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> {
originalData?: any[];
originalData?: Record<string, unknown>[];
pagination?: Pagination<M>;
pivot?: { [p: string]: any }[];
pivot?: Record<string, unknown>[];
}
export enum QueryType {
@ -366,19 +396,30 @@ export enum WhereTest {
class FieldValue {
protected readonly field: string;
protected value: any;
protected value: ModelFieldData;
protected raw: boolean;
constructor(field: string, value: any, raw: boolean) {
public constructor(field: string, value: ModelFieldData, raw: boolean) {
this.field = field;
this.value = value;
this.raw = raw;
}
public toString(first: boolean = true): string {
const valueStr = (this.raw || this.value === null || this.value instanceof ModelQuery) ? this.value :
(Array.isArray(this.value) ? `(${'?'.repeat(this.value.length).split('').join(',')})` : '?');
let field = inputToFieldOrValue(this.field);
let valueStr: string;
if (this.value instanceof ModelQuery) {
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}`;
}
@ -386,17 +427,33 @@ class FieldValue {
return '=';
}
public get variables(): any[] {
public get variables(): QueryVariable[] {
if (this.value instanceof ModelQuery) return this.value.variables;
if (this.raw || this.value === null) return [];
if (Array.isArray(this.value)) return this.value;
return [this.value];
if (this.raw || this.value === null || this.value === undefined) return [];
if (Array.isArray(this.value)) return this.value.map(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 {
public toString(first: boolean = true): string {
return `(${this.value instanceof ModelQuery ? this.value : (this.raw ? this.value : '?')}) AS \`${this.field}\``;
public toString(): string {
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 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);
this._test = test;
this.operator = operator;
@ -451,7 +508,7 @@ class WhereFieldValueGroup {
}
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;
}
@ -461,9 +518,15 @@ export type SelectFields = (string | SelectFieldValue | UpdateFieldValue)[];
export type SortDirection = 'ASC' | 'DESC';
type ModelQueryUnion = {
query: ModelQuery<any>,
query: ModelQuery<Model>,
sortBy: string,
direction: SortDirection,
limit?: 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 ModelFactory from "./ModelFactory";
@ -34,12 +34,12 @@ export default abstract class ModelRelation<S extends Model, O extends Model, R
return query;
}
public getModelID(): any {
public getModelId(): ModelFieldData {
return this.model[this.dbProperties.localKey];
}
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> {
@ -69,10 +69,13 @@ export default abstract class ModelRelation<S extends Model, O extends Model, R
protected abstract collectionToOutput(models: O[]): R;
public async eagerLoad(relations: ModelRelation<S, O, R>[], subRelations: string[] = []): Promise<ModelQueryResult<O>> {
const ids = relations.map(r => r.getModelID())
public async eagerLoad(
relations: ModelRelation<S, O, R>[],
subRelations: string[] = [],
): Promise<ModelQueryResult<O>> {
const ids = relations.map(r => r.getModelId())
.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 [];
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> {
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], []);
}
@ -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[]> {
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);
}
@ -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>> {
if (!this.paginatedCache[perPage]) this.paginatedCache[perPage] = {};
let cache = this.paginatedCache[perPage];
if (!cache) cache = this.paginatedCache[perPage] = {};
const cache = this.paginatedCache[perPage];
if (!cache[page]) {
let cachePage = cache[page];
if (!cachePage) {
const query = this.makeQuery();
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> {
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);
this.dbProperties = dbProperties;
this.constraint(query => query
.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 {
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>> {
const ids = relations.map(r => r.getModelID())
.reduce((array: O[], val) => array.indexOf(val) >= 0 ? array : [...array, val], []);
public async eagerLoad(
relations: ModelRelation<S, O, O[]>[],
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 [];
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> {
const ids = models.pivot!
.filter(p => p[`pivot.${this.dbProperties.localPivotKey}`] === this.getModelID())
if (!models.pivot) throw new Error('ModelQueryResult.pivot not loaded.');
const ids = models.pivot
.filter(p => p[`pivot.${this.dbProperties.localPivotKey}`] === this.getModelId())
.map(p => p[`pivot.${this.dbProperties.foreignPivotKey}`]);
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], []);
@ -203,7 +214,12 @@ export class ManyThroughModelRelation<S extends Model, O extends Model> extends
export class RecursiveModelRelation<M extends Model> extends ManyModelRelation<M, M> {
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);
this.constraint(query => query.recursive(this.dbProperties, 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> {
await super.populate(models);
if (this.cachedModels) {
const cachedModels = this.cachedModels;
if (cachedModels) {
let count;
do {
count = this.cachedModels.length;
this.cachedModels.push(...models.filter(model =>
!this.cachedModels!.find(cached => cached.equals(model)) &&
this.cachedModels!.find(cached => cached[this.dbProperties.localKey] === model[this.dbProperties.foreignKey])
count = cachedModels.length;
cachedModels.push(...models.filter(model =>
!cachedModels.find(cached => cached.equals(model)) && cachedModels.find(cached => {
return cached[this.dbProperties.localKey] === model[this.dbProperties.foreignKey];
}),
).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 Migration from "./Migration";
import Migration, {MigrationType} from "./Migration";
import Logger from "../Logger";
import {Type} from "../Utils";
export interface QueryResult {
readonly results: any[];
readonly results: Record<string, unknown>[];
readonly fields: FieldInfo[];
readonly other?: any;
readonly other?: Record<string, unknown>;
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);
}
@ -25,7 +29,7 @@ export default class MysqlConnectionManager {
return this.databaseReady && this.currentPool !== undefined;
}
public static registerMigrations(migrations: Type<Migration>[]) {
public static registerMigrations(migrations: MigrationType<Migration>[]): void {
if (!this.migrationsRegistered) {
this.migrationsRegistered = true;
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));
}
public static hasMigration(migration: Type<Migration>) {
public static hasMigration(migration: Type<Migration>): boolean {
for (const m of this.migrations) {
if (m.constructor === migration) return true;
}
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) {
const dbName = config.get('mysql.database');
Logger.info(`Creating database ${dbName}...`);
@ -55,11 +59,9 @@ export default class MysqlConnectionManager {
});
await new Promise((resolve, reject) => {
connection.query(`CREATE DATABASE IF NOT EXISTS ${dbName}`, (error) => {
if (error !== null) {
reject(error);
} else {
return error !== null ?
reject(error) :
resolve();
}
});
});
connection.end();
@ -89,20 +91,24 @@ export default class MysqlConnectionManager {
}
public static async endPool(): Promise<void> {
return new Promise(resolve => {
if (this.currentPool !== undefined) {
this.currentPool.end(() => {
Logger.info('Mysql connection pool ended.');
resolve();
});
this.currentPool = undefined;
} else {
resolve();
return await new Promise(resolve => {
if (this.currentPool === undefined) {
return resolve();
}
this.currentPool.end(() => {
Logger.info('Mysql connection pool ended.');
resolve();
});
this.currentPool = undefined;
});
}
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) => {
Logger.dev('SQL:', Logger.isVerboseMode() ? mysql.format(queryString, values) : queryString);
@ -115,7 +121,7 @@ export default class MysqlConnectionManager {
resolve({
results: Array.isArray(results) ? results : [],
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> {
return await new Promise<T>((resolve, reject) => {
this.pool.getConnection((err, connection) => {
this.pool.getConnection((err: MysqlError | undefined, connection: PoolConnection) => {
if (err) {
reject(err);
return;
}
connection.beginTransaction((err) => {
connection.beginTransaction((err?: MysqlError) => {
if (err) {
reject(err);
this.pool.releaseConnection(connection);
@ -137,7 +143,7 @@ export default class MysqlConnectionManager {
}
transaction(connection).then(val => {
connection.commit((err) => {
connection.commit((err?: MysqlError) => {
if (err) {
this.rejectAndRollback(connection, err, reject);
this.pool.releaseConnection(connection);
@ -156,13 +162,15 @@ export default class MysqlConnectionManager {
});
}
private static rejectAndRollback(connection: Connection, err: any, reject: (err: any) => void) {
connection.rollback((rollbackErr) => {
if (rollbackErr) {
reject(err + '\n' + rollbackErr);
} else {
private static rejectAndRollback(
connection: Connection,
err: MysqlError | undefined,
reject: (err: unknown) => void,
) {
connection.rollback((rollbackErr?: MysqlError) => {
return rollbackErr ?
reject(err + '\n' + rollbackErr) :
reject(err);
}
});
}
@ -171,7 +179,7 @@ export default class MysqlConnectionManager {
try {
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) {
if (e.code === 'ECONNREFUSED' || e.code !== 'ER_NO_SUCH_TABLE') {
throw new Error('Cannot run migrations: ' + e.code);
@ -197,16 +205,16 @@ export default class MysqlConnectionManager {
}
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> {
migrationID--;
const migration = this.migrations[migrationID];
public static async rollbackMigration(migrationId: number = 0): Promise<void> {
migrationId--;
const migration = this.migrations[migrationId];
Logger.info('Rolling back migration ', migration.version, migration.constructor.name);
await MysqlConnectionManager.wrapTransaction<void>(async c => {
await migration.rollback(c);
@ -220,12 +228,12 @@ export default class MysqlConnectionManager {
for (let i = 0; i < args.length; i++) {
if (args[i] === 'rollback') {
let migrationID = 0;
let migrationId = 0;
if (args.length > i + 1) {
migrationID = parseInt(args[i + 1]);
migrationId = parseInt(args[i + 1]);
}
await this.prepare(false);
await this.rollbackMigration(migrationID);
await this.rollbackMigration(migrationId);
return;
}
}
@ -233,4 +241,21 @@ export default class MysqlConnectionManager {
await MysqlConnectionManager.endPool();
}
}
}
}
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 {Connection} from "mysql";
import {Type} from "../Utils";
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 default class Validator<T> {
private readonly steps: ValidationStep<T>[] = [];
export default class Validator<V> {
private readonly steps: ValidationStep<V>[] = [];
private readonly validationAttributes: string[] = [];
private readonly rawValueToHuman?: (val: T) => string;
private readonly rawValueToHuman?: (val: V) => string;
private _min?: number;
private _max?: number;
public constructor(rawValueToHuman?: (val: T) => string) {
public constructor(rawValueToHuman?: (val: V) => string) {
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 connection A connection to use in case of wrapped transactions.
*/
async execute(thingName: string, value: T | undefined, onlyFormat: boolean, connection?: Connection): Promise<void> {
const bag = new ValidationBag();
public async execute(
thingName: string,
value: V | undefined,
onlyFormat: boolean,
connection?: Connection,
): Promise<void> {
const bag = new ValidationBag<V>();
for (const step of this.steps) {
if (onlyFormat && !step.isFormat) continue;
@ -41,7 +45,7 @@ export default class Validator<T> {
}
if (result === false && step.throw) {
const error: ValidationError = step.throw();
const error: ValidationError<V> = step.throw();
error.rawValueToHuman = this.rawValueToHuman;
error.thingName = thingName;
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.addStep({
@ -68,17 +72,21 @@ export default class Validator<T> {
return this;
}
public acceptUndefined(alsoAcceptEmptyString: boolean = false): Validator<T> {
public acceptUndefined(alsoAcceptEmptyString: boolean = false): Validator<V> {
this.addStep({
verifyStep: () => true,
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,
});
return this;
}
public equals(other?: T): Validator<T> {
public equals(other?: V): Validator<V> {
this.addStep({
verifyStep: val => val === other,
throw: () => new BadValueValidationError(other),
@ -87,7 +95,7 @@ export default class Validator<T> {
return this;
}
public sameAs(otherName?: string, other?: T): Validator<T> {
public sameAs(otherName?: string, other?: V): Validator<V> {
this.addStep({
verifyStep: val => val === other,
throw: () => new DifferentThanError(otherName),
@ -96,7 +104,7 @@ export default class Validator<T> {
return this;
}
public regexp(regexp: RegExp): Validator<T> {
public regexp(regexp: RegExp): Validator<V> {
this.validationAttributes.push(`pattern="${regexp}"`);
this.addStep({
verifyStep: val => regexp.test(<string><unknown>val),
@ -106,9 +114,9 @@ export default class Validator<T> {
return this;
}
public length(length: number): Validator<T> {
public length(length: number): Validator<V> {
this.addStep({
verifyStep: val => (<any>val).length === length,
verifyStep: val => isLenghtable(val) && val.length === length,
throw: () => new BadLengthValidationError(length),
isFormat: true,
});
@ -118,9 +126,9 @@ export default class Validator<T> {
/**
* @param minLength included
*/
public minLength(minLength: number): Validator<T> {
public minLength(minLength: number): Validator<V> {
this.addStep({
verifyStep: val => (<any>val).length >= minLength,
verifyStep: val => isLenghtable(val) && val.length >= minLength,
throw: () => new TooShortError(minLength),
isFormat: true,
});
@ -130,9 +138,9 @@ export default class Validator<T> {
/**
* @param maxLength included
*/
public maxLength(maxLength: number): Validator<T> {
public maxLength(maxLength: number): Validator<V> {
this.addStep({
verifyStep: val => (<any>val).length <= maxLength,
verifyStep: val => isLenghtable(val) && val.length <= maxLength,
throw: () => new TooLongError(maxLength),
isFormat: true,
});
@ -143,11 +151,11 @@ export default class Validator<T> {
* @param minLength included
* @param maxLength included
*/
public between(minLength: number, maxLength: number): Validator<T> {
public between(minLength: number, maxLength: number): Validator<V> {
this.addStep({
verifyStep: val => {
const length = (<any>val).length;
return length >= minLength && length <= maxLength;
return isLenghtable(val) &&
val.length >= minLength && val.length <= maxLength;
},
throw: () => new BadLengthValidationError(minLength, maxLength),
isFormat: true,
@ -158,12 +166,12 @@ export default class Validator<T> {
/**
* @param min included
*/
public min(min: number): Validator<T> {
public min(min: number): Validator<V> {
this.validationAttributes.push(`min="${min}"`);
this._min = min;
this.addStep({
verifyStep: val => {
return (<any>val) >= min;
return typeof val === 'number' && val >= min;
},
throw: () => new OutOfRangeValidationError(this._min, this._max),
isFormat: true,
@ -174,12 +182,12 @@ export default class Validator<T> {
/**
* @param max included
*/
public max(max: number): Validator<T> {
public max(max: number): Validator<V> {
this.validationAttributes.push(`max="${max}"`);
this._max = max;
this.addStep({
verifyStep: val => {
return (<any>val) <= max;
return typeof val === 'number' && val <= max;
},
throw: () => new OutOfRangeValidationError(this._min, this._max),
isFormat: true,
@ -187,35 +195,46 @@ export default class Validator<T> {
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({
verifyStep: async (val, thingName, c) => {
if (!foreignKey) foreignKey = thingName;
let query: ModelQuery<M>;
if (querySupplier) {
query = querySupplier().where(foreignKey, val);
query = querySupplier();
} 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;
},
throw: () => new AlreadyExistsValidationError((<any>model).table),
throw: () => new AlreadyExistsValidationError(model.table),
isFormat: false,
});
return this;
}
public exists(modelClass: Function, foreignKey?: string): Validator<T> {
public exists(modelType: ModelType<Model>, foreignKey?: string): Validator<V> {
this.addStep({
verifyStep: async (val, thingName, c) => (await (<any>modelClass).select('').where(foreignKey !== undefined ? foreignKey : thingName, val).execute(c)).results.length >= 1,
throw: () => new UnknownRelationValidationError((<any>modelClass).table, foreignKey),
verifyStep: async (val, thingName, c) => {
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,
});
return this;
}
private addStep(step: ValidationStep<T>) {
private addStep(step: ValidationStep<V>) {
this.steps.push(step);
}
@ -223,31 +242,31 @@ export default class Validator<T> {
return this.validationAttributes;
}
public step(step: number): Validator<T> {
public step(step: number): Validator<V> {
this.validationAttributes.push(`step="${step}"`);
return this;
}
}
interface ValidationStep<T> {
interrupt?: (val?: T) => boolean;
interface ValidationStep<V> {
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;
}
export class ValidationBag extends Error {
private readonly errors: ValidationError[] = [];
export class ValidationBag<V> extends Error {
private readonly errors: ValidationError<V>[] = [];
public addMessage(err: ValidationError) {
public addMessage(err: ValidationError<V>): void {
if (!err.thingName) throw new Error('Null thing name');
this.errors.push(err);
}
public addBag(otherBag: ValidationBag) {
public addBag(otherBag: ValidationBag<V>): void {
for (const error of otherBag.errors) {
this.errors.push(error);
}
@ -257,10 +276,10 @@ export class ValidationBag extends Error {
return this.errors.length > 0;
}
public getMessages(): { [p: string]: ValidationError } {
const messages: { [p: string]: ValidationError } = {};
public getMessages(): { [p: string]: ValidationError<V> } {
const messages: { [p: string]: ValidationError<V> } = {};
for (const err of this.errors) {
messages[err.thingName!] = {
messages[err.thingName || 'unknown'] = {
name: err.name,
message: err.message,
value: err.value,
@ -269,26 +288,26 @@ export class ValidationBag extends Error {
return messages;
}
public getErrors(): ValidationError[] {
public getErrors(): ValidationError<V>[] {
return this.errors;
}
}
export abstract class ValidationError extends Error {
public rawValueToHuman?: (val: any) => string;
export abstract class ValidationError<V> extends Error {
public rawValueToHuman?: (val: V) => string;
public thingName?: string;
public value?: any;
public value?: V;
public get name(): string {
return this.constructor.name;
}
}
export class BadLengthValidationError extends ValidationError {
export class BadLengthValidationError<V> extends ValidationError<V> {
private readonly expectedLength: number;
private readonly maxLength?: number;
constructor(expectedLength: number, maxLength?: number) {
public constructor(expectedLength: number, maxLength?: number) {
super();
this.expectedLength = expectedLength;
this.maxLength = maxLength;
@ -296,14 +315,14 @@ export class BadLengthValidationError extends ValidationError {
public get message(): string {
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;
constructor(minLength: number) {
public constructor(minLength: number) {
super();
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;
constructor(maxLength: number) {
public constructor(maxLength: number) {
super();
this.maxLength = maxLength;
}
@ -326,43 +345,43 @@ export class TooLongError extends ValidationError {
}
}
export class BadValueValidationError extends ValidationError {
private readonly expectedValue: any;
export class BadValueValidationError<V> extends ValidationError<V> {
private readonly expectedValue: V;
constructor(expectedValue: any) {
public constructor(expectedValue: V) {
super();
this.expectedValue = expectedValue;
}
public get message(): string {
let expectedValue = this.expectedValue;
let actualValue = this.value;
if (this.rawValueToHuman) {
expectedValue = this.rawValueToHuman(expectedValue);
actualValue = this.rawValueToHuman(actualValue);
let expectedValue: string = String(this.expectedValue);
let actualValue: string = String(this.value);
if (this.rawValueToHuman && this.value) {
expectedValue = this.rawValueToHuman(this.expectedValue);
actualValue = this.rawValueToHuman(this.value);
}
return `Expected: ${expectedValue}; got: ${actualValue}.`
return `Expected: ${expectedValue}; got: ${actualValue}.`;
}
}
export class DifferentThanError extends ValidationError {
private readonly otherName: any;
export class DifferentThanError<V> extends ValidationError<V> {
private readonly otherName?: string;
constructor(otherName: any) {
public constructor(otherName?: string) {
super();
this.otherName = otherName;
}
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 max?: number;
constructor(min?: number, max?: number) {
public constructor(min?: number, max?: number) {
super();
this.min = min;
this.max = max;
@ -374,32 +393,32 @@ export class OutOfRangeValidationError extends ValidationError {
} else if (this.max === undefined) {
return `${this.thingName} must be at least ${this.min}`;
}
let min: any = this.min;
let max: any = this.max;
let min: string = String(this.min);
let max: string = String(this.max);
if (this.rawValueToHuman) {
min = this.rawValueToHuman(min);
max = this.rawValueToHuman(max);
min = this.rawValueToHuman(this.min as unknown as V);
max = this.rawValueToHuman(this.max as unknown as V);
}
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 {
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 {
return `${this.thingName} is required.`;
}
}
export class AlreadyExistsValidationError extends ValidationError {
export class AlreadyExistsValidationError<V> extends ValidationError<V> {
private readonly table: string;
constructor(table: string) {
public constructor(table: string) {
super();
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 foreignKey?: string;
constructor(table: string, foreignKey?: string) {
public constructor(table: string, foreignKey?: string) {
super();
this.table = table;
this.foreignKey = foreignKey;
@ -424,15 +443,24 @@ export class UnknownRelationValidationError extends ValidationError {
}
}
export class FileError extends ValidationError {
private readonly m: string;
export class FileError<V> extends ValidationError<V> {
private readonly _message: string;
constructor(message: string) {
public constructor(message: string) {
super();
this.m = message;
this._message = message;
}
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 {
this.get('/', this.getIndex, 'backend', RequireAuthMiddleware, RequireAdminMiddleware);
if (User.isApprovalMode()) {
this.get('/accounts-approval', this.getAccountApproval, 'accounts-approval', RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/accounts-approval/approve', this.postApproveAccount, 'approve-account', RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/accounts-approval/reject', this.postRejectAccount, 'reject-account', RequireAuthMiddleware, RequireAdminMiddleware);
this.get('/accounts-approval', this.getAccountApproval, 'accounts-approval',
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;
await account.save();
if (email) {
if (email && email.email) {
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: true,
link: config.get<string>('base_url') + Controller.route('auth'),
}).send(email.email!);
}).send(email.email);
}
req.flash('success', `Account successfully approved.`);
@ -87,10 +90,10 @@ export default class BackendController extends Controller {
await account.delete();
if (email) {
if (email && email.email) {
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: false,
}).send(email.email!);
}).send(email.email);
}
req.flash('success', `Account successfully deleted.`);

View File

@ -29,4 +29,4 @@ export default class CreateLogsTable extends Migration {
public registerModels(): void {
ModelFactory.register(Log);
}
}
}

View File

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

View File

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

View File

@ -1,10 +1,14 @@
import {Files} from "formidable";
import {Type} from "../Utils";
import Middleware from "../Middleware";
import {FlashMessages} from "../components/SessionComponent";
declare global {
namespace Express {
export interface Request {
getSession(): Session;
files: Files;
@ -13,15 +17,15 @@ declare global {
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 {
redirectBack(defaultUrl?: string): any;
redirectBack(defaultUrl?: string): void;
}
}
}
}

View File

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

View File

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

View File

@ -31,7 +31,11 @@ describe('Test ModelQuery', () => {
});
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.variables).toStrictEqual([]);
});
@ -81,6 +85,6 @@ describe('Test ModelQuery', () => {
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.variables).toStrictEqual(['v2'])
expect(query.variables).toStrictEqual(['v2']);
});
});

View File

@ -1,7 +1,6 @@
import {setupMailServer, teardownMailServer} from "./_mail_server";
import Application from "../src/Application";
import Migration from "../src/db/Migration";
import {Type} from "../src/Utils";
import Migration, {MigrationType} from "../src/db/Migration";
import {MIGRATIONS} from "./_migrations";
import ExpressAppComponent from "../src/components/ExpressAppComponent";
import RedisComponent from "../src/components/RedisComponent";
@ -17,8 +16,9 @@ import FormHelperComponent from "../src/components/FormHelperComponent";
import RedirectBackComponent from "../src/components/RedirectBackComponent";
import ServeStaticDirectoryComponent from "../src/components/ServeStaticDirectoryComponent";
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;
beforeAll(async (done) => {
@ -30,8 +30,21 @@ export default function useApp(appSupplier?: (addr: string, port: number) => Tes
});
afterAll(async (done) => {
await app.stop();
await teardownMailServer();
const errors = [];
try {
await app.stop();
} catch (e) {
errors.push(e);
}
try {
await teardownMailServer();
} catch (e) {
errors.push(e);
}
if (errors.length > 0) throw errors;
done();
});
}
@ -41,23 +54,23 @@ export class TestApp extends Application {
private readonly port: number;
private expressAppComponent?: ExpressAppComponent;
constructor(addr: string, port: number) {
super(require('../package.json').version, true);
public constructor(addr: string, port: number) {
super(packageJson.version, true);
this.addr = addr;
this.port = port;
}
protected getMigrations(): Type<Migration>[] {
protected getMigrations(): MigrationType<Migration>[] {
return MIGRATIONS;
}
protected async init(): Promise<void> {
this.registerComponents();
this.registerWebSocketListeners();
this.registerControllers();
this.registerWebSocketListeners?.();
this.registerControllers?.();
}
protected registerComponents() {
protected registerComponents(): void {
this.expressAppComponent = new ExpressAppComponent(this.addr, this.port);
const redisComponent = new RedisComponent();
const mysqlComponent = new MysqlComponent();
@ -84,7 +97,7 @@ export class TestApp extends Application {
// Auth
this.use(new AuthComponent(new class extends AuthGuard<MagicLink> {
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());
}
protected registerWebSocketListeners() {
}
protected registerWebSocketListeners?(): void;
protected registerControllers() {
}
protected registerControllers?(): void;
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({
ip: 'localhost',
});
export async function setupMailServer() {
await new Promise((resolve, reject) => MAIL_SERVER.listen((err: Error) => {
export async function setupMailServer(): Promise<void> {
await new Promise((resolve, reject) => MAIL_SERVER.listen((err?: Error) => {
if (err) reject(err);
else resolve();
}));
};
}
export async function teardownMailServer() {
await new Promise((resolve, reject) => MAIL_SERVER.close((err: Error) => {
export async function teardownMailServer(): Promise<void> {
await new Promise((resolve, reject) => MAIL_SERVER.close((err?: Error) => {
if (err) reject(err);
else resolve();
}));
};
}

View File

@ -12,4 +12,4 @@ export const MIGRATIONS = [
FixUserMainEmailRelation,
DropNameFromUsers,
CreateMagicLinksTable,
];
];

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,
"strict": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
"allowSyntheticDefaultImports": true,
"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>
<button type="submit" class="primary">Register</button>
{{ macros.csrf(getCSRFToken) }}
{{ macros.csrf(getCsrfToken) }}
</form>
{% else %}
<form action="{{ action }}" method="POST" id="login-form">
<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">
{{ 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>
<button type="submit">Authenticate</button>
{{ macros.csrf(getCSRFToken) }}
{{ macros.csrf(getCsrfToken) }}
</form>
{% endif %}
</div>

View File

@ -28,14 +28,14 @@
<form action="{{ route('approve-account') }}" method="POST">
<input type="hidden" name="user_id" value="{{ user.id }}">
<button class="success"><i data-feather="check"></i> Approve</button>
{{ macros.csrf(getCSRFToken) }}
{{ macros.csrf(getCsrfToken) }}
</form>
<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.">
<input type="hidden" name="user_id" value="{{ user.id }}">
<button class="danger"><i data-feather="check"></i> Reject</button>
{{ macros.csrf(getCSRFToken) }}
{{ macros.csrf(getCsrfToken) }}
</form>
</div>
</td>

View File

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