Browse Source

Add many eslint rules and fix all linting issues

swaf-creation
Alice Gaudon 5 months ago
parent
commit
79d704083a
75 changed files with 1402 additions and 3966 deletions
  1. +93
    -2
      .eslintrc.json
  2. +6
    -1
      jest.config.js
  3. +25
    -22
      src/Application.ts
  4. +8
    -12
      src/ApplicationComponent.ts
  5. +1
    -1
      src/CacheProvider.ts
  6. +50
    -30
      src/Controller.ts
  7. +1
    -1
      src/Extendable.ts
  8. +2
    -2
      src/FileUploadMiddleware.ts
  9. +18
    -22
      src/HttpError.ts
  10. +53
    -49
      src/Logger.ts
  11. +15
    -13
      src/Mail.ts
  12. +2
    -2
      src/Mails.ts
  13. +5
    -0
      src/Middleware.ts
  14. +2
    -2
      src/Pagination.ts
  15. +1
    -1
      src/SecurityError.ts
  16. +21
    -18
      src/Throttler.ts
  17. +11
    -11
      src/Utils.ts
  18. +6
    -2
      src/WebSocketListener.ts
  19. +3
    -3
      src/auth/AuthComponent.ts
  20. +4
    -4
      src/auth/AuthController.ts
  21. +13
    -19
      src/auth/AuthGuard.ts
  22. +5
    -4
      src/auth/AuthProof.ts
  23. +3
    -3
      src/auth/MailController.ts
  24. +34
    -26
      src/auth/magic_link/MagicLinkAuthController.ts
  25. +25
    -14
      src/auth/magic_link/MagicLinkController.ts
  26. +17
    -13
      src/auth/magic_link/MagicLinkWebSocketListener.ts
  27. +1
    -1
      src/auth/migrations/AddApprovedFieldToUsersTable.ts
  28. +1
    -1
      src/auth/migrations/CreateMagicLinksTable.ts
  29. +1
    -3
      src/auth/migrations/DropNameFromUsers.ts
  30. +2
    -6
      src/auth/migrations/FixUserMainEmailRelation.ts
  31. +18
    -25
      src/auth/models/MagicLink.ts
  32. +3
    -2
      src/auth/models/User.ts
  33. +1
    -4
      src/auth/models/UserApprovedComponent.ts
  34. +1
    -1
      src/auth/models/UserEmail.ts
  35. +12
    -11
      src/components/AutoUpdateComponent.ts
  36. +11
    -14
      src/components/CsrfProtectionComponent.ts
  37. +7
    -5
      src/components/ExpressAppComponent.ts
  38. +4
    -8
      src/components/FormHelperComponent.ts
  39. +36
    -20
      src/components/LogRequestsComponent.ts
  40. +2
    -2
      src/components/MailComponent.ts
  41. +2
    -2
      src/components/MaintenanceComponent.ts
  42. +2
    -2
      src/components/MysqlComponent.ts
  43. +2
    -8
      src/components/NunjucksComponent.ts
  44. +11
    -8
      src/components/RedirectBackComponent.ts
  45. +10
    -9
      src/components/RedisComponent.ts
  46. +3
    -6
      src/components/ServeStaticDirectoryComponent.ts
  47. +25
    -10
      src/components/SessionComponent.ts
  48. +7
    -6
      src/components/WebSocketServerComponent.ts
  49. +7
    -2
      src/db/Migration.ts
  50. +68
    -46
      src/db/Model.ts
  51. +14
    -13
      src/db/ModelComponent.ts
  52. +28
    -22
      src/db/ModelFactory.ts
  53. +115
    -52
      src/db/ModelQuery.ts
  54. +49
    -31
      src/db/ModelRelation.ts
  55. +67
    -42
      src/db/MysqlConnectionManager.ts
  56. +118
    -90
      src/db/Validator.ts
  57. +10
    -7
      src/helpers/BackendController.ts
  58. +1
    -1
      src/migrations/CreateLogsTable.ts
  59. +1
    -4
      src/migrations/CreateMigrationsTable.ts
  60. +7
    -7
      src/models/Log.ts
  61. +9
    -5
      src/types/Express.d.ts
  62. +4
    -4
      test/CsrfProtectionComponent.test.ts
  63. +38
    -38
      test/Model.test.ts
  64. +6
    -2
      test/ModelQuery.test.ts
  65. +28
    -19
      test/_app.ts
  66. +7
    -7
      test/_mail_server.ts
  67. +1
    -1
      test/_migrations.ts
  68. +216
    -0
      test/types/maildev.d.ts
  69. +1
    -1
      test/views/test/csrf.njk
  70. +0
    -2
      tsconfig.json
  71. +14
    -0
      tsconfig.test.json
  72. +0
    -3132
      tsconfig.tsbuildinfo
  73. +3
    -3
      views/auth/auth.njk
  74. +2
    -2
      views/backend/accounts_approval.njk
  75. +2
    -2
      views/macros.njk

+ 93
- 2
.eslintrc.json 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
}
]
}
}
]
}

+ 6
- 1
jest.config.js 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',
};

+ 25
- 22
src/Application.ts 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;
}
}

+ 8
- 12
src/ApplicationComponent.ts 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;
}
}

+ 1
- 1
src/CacheProvider.ts View File

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

+ 50
- 30
src/Controller.ts 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 } = {};
public static route(route: string, params: RouteParams = [], query: ParsedUrlQueryInput = {}, absolute: boolean = false): string {
private static readonly routes: { [p: string]: string | undefined } = {};
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;
}
}


+ 1
- 1
src/Extendable.ts 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;
}
}

+ 2
- 2
src/FileUploadMiddleware.ts 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();
}
}
}

+ 18
- 22
src/HttpError.ts 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;
}
}
}

+ 53
- 49
src/Logger.ts 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;

+ 15
- 13
src/Mail.ts 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);
}
}
}

+ 2
- 2
src/Mails.ts 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(


+ 5
- 0
src/Middleware.ts 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;
}

+ 2
- 2
src/Pagination.ts 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;
}
}
}

+ 1
- 1
src/SecurityError.ts View File

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

+ 21
- 18
src/Throttler.ts 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);
}
}
}

+ 11
- 11
src/Utils.ts 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');
}

+ 6
- 2
src/WebSocketListener.ts 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>;
}

+ 3
- 3
src/auth/AuthComponent.ts 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;
}


+ 4
- 4
src/auth/AuthController.ts 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() || '/');
}
}
}

+ 13
- 19
src/auth/AuthGuard.ts 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.`);
}
}


+ 5
- 4
src/auth/AuthProof.ts 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>;
}
}

+ 3
- 3
src/auth/MailController.ts 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));
}
}
}

+ 34
- 26
src/auth/magic_link/MagicLinkAuthController.ts 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[] = [];
const userEmail = UserEmail.create({
user_id: user.id,
email: magicLink.getEmail(),
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.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</