Move route building to common subproject, fix Time export

This commit is contained in:
Alice Gaudon 2021-06-01 14:34:04 +02:00
parent 1b221c590f
commit 7ccd335649
19 changed files with 185 additions and 145 deletions

View File

@ -6,6 +6,7 @@ import * as path from "path";
import ApplicationComponent from "./ApplicationComponent.js"; import ApplicationComponent from "./ApplicationComponent.js";
import CacheProvider from "./CacheProvider.js"; import CacheProvider from "./CacheProvider.js";
import {route, setPublicUrl} from "./common/Routing.js";
import FrontendToolsComponent from "./components/FrontendToolsComponent.js"; import FrontendToolsComponent from "./components/FrontendToolsComponent.js";
import LogRequestsComponent from "./components/LogRequestsComponent.js"; import LogRequestsComponent from "./components/LogRequestsComponent.js";
import RedisComponent from "./components/RedisComponent.js"; import RedisComponent from "./components/RedisComponent.js";
@ -37,6 +38,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
protected constructor(version: string, ignoreCommandLine: boolean = false) { protected constructor(version: string, ignoreCommandLine: boolean = false) {
this.version = version; this.version = version;
this.ignoreCommandLine = ignoreCommandLine; this.ignoreCommandLine = ignoreCommandLine;
setPublicUrl(config.get<string>('app.public_url'));
} }
protected abstract getMigrations(): MigrationType<Migration>[]; protected abstract getMigrations(): MigrationType<Migration>[];
@ -163,7 +166,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
}, },
html: () => { html: () => {
req.flash('validation', bag.getMessages()); req.flash('validation', bag.getMessages());
res.redirect(req.getPreviousUrl() || Controller.route('home')); res.redirect(req.getPreviousUrl() || route('home'));
}, },
}); });
return; return;

View File

@ -1,55 +1,13 @@
import config from "config";
import express, {IRouter, RequestHandler, Router} from "express"; import express, {IRouter, RequestHandler, Router} from "express";
import {PathParams} from "express-serve-static-core"; import {PathParams} from "express-serve-static-core";
import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring";
import Application from "./Application.js"; import Application from "./Application.js";
import {registerRoute} from "./common/Routing.js";
import FileUploadMiddleware from "./FileUploadMiddleware.js"; import FileUploadMiddleware from "./FileUploadMiddleware.js";
import {logger} from "./Logger.js"; import {logger} from "./Logger.js";
import Middleware, {MiddlewareType} from "./Middleware.js"; import Middleware, {MiddlewareType} from "./Middleware.js";
export default abstract class Controller { export default abstract class Controller {
/**
* TODO: this should not be static, it should actually be bound to an app instance.
*/
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}.`);
const regExp = this.getRouteParamRegExp('[a-zA-Z0-9_-]+', 'g');
if (typeof params === 'string' || typeof params === 'number') {
path = path.replace(regExp, '' + params);
} else if (Array.isArray(params)) {
let i = 0;
for (const match of path.matchAll(regExp)) {
if (match.length > 0) {
path = path.replace(match[0], typeof params[i] !== 'undefined' ? params[i] : '');
}
i++;
}
path = path.replace(/\/+/g, '/');
} else {
for (const key of Object.keys(params)) {
path = path.replace(this.getRouteParamRegExp(key), params[key].toString());
}
}
const queryStr = querystring.stringify(query);
return `${absolute ? config.get<string>('public_url') : ''}${path}` + (queryStr.length > 0 ? '?' + queryStr : '');
}
private static getRouteParamRegExp(key: string, flags?: string): RegExp {
return new RegExp(`:${key}(\\(.+?\\))?\\??`, flags);
}
private readonly router: Router = express.Router(); private readonly router: Router = express.Router();
private readonly fileUploadFormRouter: Router = express.Router(); private readonly fileUploadFormRouter: Router = express.Router();
private app?: Application; private app?: Application;
@ -183,13 +141,15 @@ export default abstract class Controller {
routePath = (prefix !== '/' ? prefix : '') + path; routePath = (prefix !== '/' ? prefix : '') + path;
} }
if (!Controller.routes[routeName]) { if (typeof routePath !== 'string') {
if (typeof routePath === 'string') { logger.warn(`Cannot assign path to route ${routeName}.`);
logger.info(`Route ${routeName} has path ${routePath}`); return;
Controller.routes[routeName] = routePath; }
} else {
logger.warn(`Cannot assign path to route ${routeName}.`); if (registerRoute(routeName, routePath)) {
} logger.info(`Route ${routeName} has path ${routePath}`);
} else {
logger.warn(`Couldn't register ${routeName} for path ${routePath}`);
} }
} }
@ -202,5 +162,3 @@ export default abstract class Controller {
this.app = app; this.app = app;
} }
} }
export type RouteParams = { [p: string]: string | number } | string[] | string | number;

View File

@ -6,6 +6,9 @@
<style>%css%</style> <style>%css%</style>
<script type="module" defer> <script type="module" defer>
import View from '/js/views/%canonicalViewName%.js'; import View from '/js/views/%canonicalViewName%.js';
import * as Routing from '/js/Routing.js';
const setRoutes = Routing.R.setRoutes;
const setPublicUrl = Routing.R.setPublicUrl;
import * as stores from '/js/stores.js'; import * as stores from '/js/stores.js';
const localStore = stores.s.locals; const localStore = stores.s.locals;
@ -16,6 +19,9 @@
: `'${key}'`]; : `'${key}'`];
}); });
setRoutes(%routes%);
setPublicUrl(%publicUrl%);
new View({ new View({
hydrate: true, hydrate: true,
target: document.body, target: document.body,

View File

@ -1,7 +1,8 @@
import config from "config"; import config from "config";
import {Request, Response} from "express"; import {Request, Response} from "express";
import Time from "../common/Time.js"; import {route} from "../common/Routing.js";
import {Time} from "../common/Time.js";
import Controller from "../Controller.js"; import Controller from "../Controller.js";
import ModelFactory from "../db/ModelFactory.js"; import ModelFactory from "../db/ModelFactory.js";
import Validator, {EMAIL_REGEX, InvalidFormatValidationError} from "../db/Validator.js"; import Validator, {EMAIL_REGEX, InvalidFormatValidationError} from "../db/Validator.js";
@ -81,14 +82,14 @@ export default class AccountController extends Controller {
const nameChangedAt = userNameComponent.getNameChangedAt()?.getTime() || Date.now(); const nameChangedAt = userNameComponent.getNameChangedAt()?.getTime() || Date.now();
const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period'); const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period');
req.flash('error', `Your can't change your name until ${new Date(nameChangedAt + nameChangeWaitPeriod)}.`); req.flash('error', `Your can't change your name until ${new Date(nameChangedAt + nameChangeWaitPeriod)}.`);
res.redirect(Controller.route('account')); res.redirect(route('account'));
return; return;
} }
await user.save(); await user.save();
req.flash('success', `Your name was successfully changed to ${req.body.name}.`); req.flash('success', `Your name was successfully changed to ${req.body.name}.`);
res.redirect(Controller.route('account')); res.redirect(route('account'));
} }
protected async postChangePassword(req: Request, res: Response): Promise<void> { protected async postChangePassword(req: Request, res: Response): Promise<void> {
@ -101,7 +102,7 @@ export default class AccountController extends Controller {
const passwordComponent = user.as(UserPasswordComponent); const passwordComponent = user.as(UserPasswordComponent);
if (passwordComponent.hasPassword() && !await passwordComponent.verifyPassword(req.body.current_password)) { if (passwordComponent.hasPassword() && !await passwordComponent.verifyPassword(req.body.current_password)) {
req.flash('error', 'Invalid current password.'); req.flash('error', 'Invalid current password.');
res.redirect(Controller.route('account')); res.redirect(route('account'));
return; return;
} }
@ -109,7 +110,7 @@ export default class AccountController extends Controller {
await user.save(); await user.save();
req.flash('success', 'Password changed successfully.'); req.flash('success', 'Password changed successfully.');
res.redirect(Controller.route('account')); res.redirect(route('account'));
} }
protected async postRemovePassword(req: Request, res: Response): Promise<void> { protected async postRemovePassword(req: Request, res: Response): Promise<void> {
@ -117,7 +118,7 @@ export default class AccountController extends Controller {
const mainEmail = await user.mainEmail.get(); const mainEmail = await user.mainEmail.get();
if (!mainEmail || !mainEmail.email) { if (!mainEmail || !mainEmail.email) {
req.flash('error', 'You can\'t remove your password without adding an email address first.'); req.flash('error', 'You can\'t remove your password without adding an email address first.');
res.redirect(Controller.route('account')); res.redirect(route('account'));
return; return;
} }
@ -126,14 +127,14 @@ export default class AccountController extends Controller {
this.getApp(), this.getApp(),
req.getSession().id, req.getSession().id,
AuthMagicLinkActionType.REMOVE_PASSWORD, AuthMagicLinkActionType.REMOVE_PASSWORD,
Controller.route('account'), route('account'),
mainEmail.email, mainEmail.email,
this.removePasswordMailTemplate, this.removePasswordMailTemplate,
{}, {},
); );
res.redirect(Controller.route('magic_link_lobby', undefined, { res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: Controller.route('account'), redirect_uri: route('account'),
})); }));
} }
@ -156,7 +157,7 @@ export default class AccountController extends Controller {
this.getApp(), this.getApp(),
req.getSession().id, req.getSession().id,
AuthMagicLinkActionType.ADD_EMAIL, AuthMagicLinkActionType.ADD_EMAIL,
Controller.route('account'), route('account'),
email, email,
this.addEmailMailTemplate, this.addEmailMailTemplate,
{ {
@ -164,8 +165,8 @@ export default class AccountController extends Controller {
}, },
); );
res.redirect(Controller.route('magic_link_lobby', undefined, { res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: Controller.route('account'), redirect_uri: route('account'),
})); }));
} }
@ -188,7 +189,7 @@ export default class AccountController extends Controller {
await user.save(); await user.save();
req.flash('success', 'This email was successfully set as your main address.'); req.flash('success', 'This email was successfully set as your main address.');
res.redirect(Controller.route('account')); res.redirect(route('account'));
} }
protected async postRemoveEmail(req: Request, res: Response): Promise<void> { protected async postRemoveEmail(req: Request, res: Response): Promise<void> {
@ -208,6 +209,6 @@ export default class AccountController extends Controller {
await userEmail.delete(); await userEmail.delete();
req.flash('success', 'This email was successfully removed from your account.'); req.flash('success', 'This email was successfully removed from your account.');
res.redirect(Controller.route('account')); res.redirect(route('account'));
} }
} }

View File

@ -2,7 +2,7 @@ import {NextFunction, Request, Response} from "express";
import Application from "../Application.js"; import Application from "../Application.js";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import Controller from "../Controller.js"; import {route} from "../common/Routing.js";
import {ForbiddenHttpError} from "../HttpError.js"; import {ForbiddenHttpError} from "../HttpError.js";
import Middleware from "../Middleware.js"; import Middleware from "../Middleware.js";
import AuthGuard from "./AuthGuard.js"; import AuthGuard from "./AuthGuard.js";
@ -66,7 +66,7 @@ export class RequireRequestAuthMiddleware extends Middleware {
} }
req.flash('error', `You must be logged in to access ${req.url}.`); req.flash('error', `You must be logged in to access ${req.url}.`);
res.redirect(Controller.route('auth', undefined, { res.redirect(route('auth', undefined, {
redirect_uri: req.url, redirect_uri: req.url,
})); }));
} }
@ -102,7 +102,7 @@ export class RequireAuthMiddleware extends Middleware {
} }
req.flash('error', `You must be logged in to access ${req.url}.`); req.flash('error', `You must be logged in to access ${req.url}.`);
res.redirect(Controller.route('auth', undefined, { res.redirect(route('auth', undefined, {
redirect_uri: req.url, redirect_uri: req.url,
})); }));
} }
@ -117,7 +117,7 @@ export class RequireGuestMiddleware extends Middleware {
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> { protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForSession(req.getSession()); const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForSession(req.getSession());
if (proofs.length > 0) { if (proofs.length > 0) {
res.redirect(Controller.route('home')); res.redirect(route('home'));
return; return;
} }

View File

@ -4,8 +4,8 @@ import {Session, SessionData} from "express-session";
import {Connection} from "mysql"; import {Connection} from "mysql";
import Application from "../Application.js"; import Application from "../Application.js";
import {route} from "../common/Routing.js";
import MailComponent from "../components/MailComponent.js"; import MailComponent from "../components/MailComponent.js";
import Controller from "../Controller.js";
import MysqlConnectionManager from "../db/MysqlConnectionManager.js"; import MysqlConnectionManager from "../db/MysqlConnectionManager.js";
import Mail from "../mail/Mail.js"; import Mail from "../mail/Mail.js";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails.js"; import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails.js";
@ -150,7 +150,7 @@ export default class AuthGuard {
username: user.asOptional(UserNameComponent)?.getName() || username: user.asOptional(UserNameComponent)?.getName() ||
(await user.mainEmail.get())?.getOrFail('email') || (await user.mainEmail.get())?.getOrFail('email') ||
'Could not find an identifier', 'Could not find an identifier',
link: config.get<string>('public_url') + Controller.route('accounts-approval'), link: route('accounts-approval', {}, {}, true),
}), config.get<string>('app.contact_email')); }), config.get<string>('app.contact_email'));
} }
} }

View File

@ -3,7 +3,7 @@ import {Session} from "express-session";
import geoip from "geoip-lite"; import geoip from "geoip-lite";
import Application from "../../Application.js"; import Application from "../../Application.js";
import Controller from "../../Controller.js"; import {route} from "../../common/Routing.js";
import ModelFactory from "../../db/ModelFactory.js"; import ModelFactory from "../../db/ModelFactory.js";
import {WhereTest} from "../../db/ModelQuery.js"; import {WhereTest} from "../../db/ModelQuery.js";
import Validator, {EMAIL_REGEX} from "../../db/Validator.js"; import Validator, {EMAIL_REGEX} from "../../db/Validator.js";
@ -55,7 +55,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
if (pendingLink) { if (pendingLink) {
if (await pendingLink.isValid()) { if (await pendingLink.isValid()) {
res.redirect(Controller.route('magic_link_lobby', undefined, { res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: req.getIntendedUrl() || pendingLink.original_url || undefined, redirect_uri: req.getIntendedUrl() || pendingLink.original_url || undefined,
})); }));
return true; return true;
@ -105,7 +105,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
this.app, this.app,
req.getSession().id, req.getSession().id,
actionType, actionType,
Controller.route('auth', undefined, { route('auth', undefined, {
redirect_uri: req.getIntendedUrl() || undefined, redirect_uri: req.getIntendedUrl() || undefined,
}), }),
email, email,
@ -120,7 +120,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
}, },
); );
res.redirect(Controller.route('magic_link_lobby', undefined, { res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: req.getIntendedUrl(), redirect_uri: req.getIntendedUrl(),
})); }));
} }

View File

@ -1,8 +1,8 @@
import config from "config"; import config from "config";
import {Request, Response} from "express"; import {Request, Response} from "express";
import {ParsedUrlQueryInput} from "querystring";
import Application from "../../Application.js"; import Application from "../../Application.js";
import {QueryParamsRecord, route} from "../../common/Routing.js";
import MailComponent from "../../components/MailComponent.js"; import MailComponent from "../../components/MailComponent.js";
import Controller from "../../Controller.js"; import Controller from "../../Controller.js";
import {QueryVariable} from "../../db/MysqlConnectionManager.js"; import {QueryVariable} from "../../db/MysqlConnectionManager.js";
@ -30,7 +30,7 @@ export default class MagicLinkController<A extends Application> extends Controll
original_url: string, original_url: string,
email: string, email: string,
mailTemplate: MailTemplate, mailTemplate: MailTemplate,
data: ParsedUrlQueryInput, data: QueryParamsRecord,
magicLinkData: Record<string, QueryVariable> = {}, magicLinkData: Record<string, QueryVariable> = {},
): Promise<void> { ): Promise<void> {
Throttler.throttle('magic_link', process.env.NODE_ENV === 'test' ? 10 : 2, MagicLink.validityPeriod(), Throttler.throttle('magic_link', process.env.NODE_ENV === 'test' ? 10 : 2, MagicLink.validityPeriod(),
@ -49,10 +49,10 @@ export default class MagicLinkController<A extends Application> extends Controll
// Send email // Send email
await app.as(MailComponent).sendMail(new Mail(mailTemplate, Object.assign(data, { await app.as(MailComponent).sendMail(new Mail(mailTemplate, Object.assign(data, {
link: `${config.get<string>('public_url')}${Controller.route('magic_link', undefined, { link: `${route('magic_link', undefined, {
id: link.id, id: link.id,
token: token, token: token,
})}`, }, true)}`,
})), email); })), email);
} }
@ -90,7 +90,7 @@ export default class MagicLinkController<A extends Application> extends Controll
} catch (e) { } catch (e) {
if (e instanceof PendingApprovalAuthError) { if (e instanceof PendingApprovalAuthError) {
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`); req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
res.redirect(Controller.route('auth')); res.redirect(route('auth'));
return null; return null;
} else { } else {
throw e; throw e;
@ -191,7 +191,7 @@ export default class MagicLinkController<A extends Application> extends Controll
// Auth success // Auth success
const name = user.asOptional(UserNameComponent)?.getName(); const name = user.asOptional(UserNameComponent)?.getName();
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`); req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
res.redirect(req.getIntendedUrl() || Controller.route('home')); res.redirect(req.getIntendedUrl() || route('home'));
} }
break; break;
} }
@ -211,7 +211,7 @@ export default class MagicLinkController<A extends Application> extends Controll
if (await UserEmail.select().with('user').where('email', email).first()) { if (await UserEmail.select().with('user').where('email', email).first()) {
req.flash('error', 'An account already exists with this email address.' + req.flash('error', 'An account already exists with this email address.' +
' Please first remove it there before adding it here.'); ' Please first remove it there before adding it here.');
res.redirect(Controller.route('account')); res.redirect(route('account'));
return; return;
} }
@ -228,7 +228,7 @@ export default class MagicLinkController<A extends Application> extends Controll
} }
req.flash('success', `Email address ${userEmail.email} successfully added.`); req.flash('success', `Email address ${userEmail.email} successfully added.`);
res.redirect(Controller.route('account')); res.redirect(route('account'));
break; break;
} }
@ -250,7 +250,7 @@ export default class MagicLinkController<A extends Application> extends Controll
} }
req.flash('success', `Password successfully removed.`); req.flash('success', `Password successfully removed.`);
res.redirect(Controller.route('account')); res.redirect(route('account'));
break; break;
} }

View File

@ -2,7 +2,7 @@ import {Request, Response} from "express";
import {Session} from "express-session"; import {Session} from "express-session";
import Application from "../../Application.js"; import Application from "../../Application.js";
import Controller from "../../Controller.js"; import {route} from "../../common/Routing.js";
import ModelFactory from "../../db/ModelFactory.js"; import ModelFactory from "../../db/ModelFactory.js";
import Validator, {InvalidFormatValidationError} from "../../db/Validator.js"; import Validator, {InvalidFormatValidationError} from "../../db/Validator.js";
import {ServerError} from "../../HttpError.js"; import {ServerError} from "../../HttpError.js";
@ -73,7 +73,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
if (e instanceof PendingApprovalAuthError) { if (e instanceof PendingApprovalAuthError) {
req.flash('error', 'Your account is still being reviewed.'); req.flash('error', 'Your account is still being reviewed.');
res.redirect(Controller.route('auth')); res.redirect(route('auth'));
return; return;
} else { } else {
const err = new InvalidFormatValidationError('Invalid password.'); const err = new InvalidFormatValidationError('Invalid password.');
@ -86,7 +86,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
} }
req.flash('success', `Welcome, ${user.name}.`); req.flash('success', `Welcome, ${user.name}.`);
res.redirect(req.getIntendedUrl() || Controller.route('home')); res.redirect(req.getIntendedUrl() || route('home'));
} }
public async attemptRegister(req: Request, res: Response, identifier: string): Promise<void> { public async attemptRegister(req: Request, res: Response, identifier: string): Promise<void> {
@ -124,7 +124,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
} catch (e) { } catch (e) {
if (e instanceof PendingApprovalAuthError) { if (e instanceof PendingApprovalAuthError) {
req.flash('info', `Your account was successfully created and is pending review from an administrator.`); req.flash('info', `Your account was successfully created and is pending review from an administrator.`);
res.redirect(Controller.route('auth')); res.redirect(route('auth'));
return; return;
} else { } else {
throw e; throw e;
@ -134,7 +134,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
const user = await passwordAuthProof.getResource(); const user = await passwordAuthProof.getResource();
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).getName()}.`); req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).getName()}.`);
res.redirect(req.getIntendedUrl() || Controller.route('home')); res.redirect(req.getIntendedUrl() || route('home'));
} }
} }

68
src/common/Routing.ts Normal file
View File

@ -0,0 +1,68 @@
export type RouteParams = { [p: string]: string | number } | string[] | string | number;
export type QueryParamsRecord = Record<string, string | number | boolean | null | undefined>;
export type QueryParams = string[][] | QueryParamsRecord | string | URLSearchParams;
export let routes: Record<string, string | undefined> = {};
export function setRoutes(newRoutes: Record<string, string | undefined>): void {
routes = newRoutes;
}
export function registerRoute(routeName: string, routePath: string): boolean {
if (!routes[routeName]) {
routes[routeName] = routePath;
return true;
}
return false;
}
export let publicUrl: string;
export function setPublicUrl(newPublicUrl: string): void {
publicUrl = newPublicUrl;
}
export function route(
route: string,
params: RouteParams = [],
query: QueryParams = '',
absolute: boolean = false,
): string {
let path = routes[route];
if (path === undefined) throw new Error(`Unknown route for name ${route}.`);
const regExp = getRouteParamRegExp('[a-zA-Z0-9_-]+', 'g');
if (typeof params === 'string' || typeof params === 'number') {
path = path.replace(regExp, '' + params);
} else if (Array.isArray(params)) {
let i = 0;
for (const match of path.matchAll(regExp)) {
if (match.length > 0) {
path = path.replace(match[0], typeof params[i] !== 'undefined' ? params[i] : '');
}
i++;
}
path = path.replace(/\/+/g, '/');
} else {
for (const key of Object.keys(params)) {
path = path.replace(getRouteParamRegExp(key), params[key].toString());
}
}
let filteredQuery: string[][] | Record<string, string> | string | URLSearchParams;
if (typeof query !== "string" && !(query instanceof URLSearchParams) && !Array.isArray(query)) {
for (const key of Object.keys(query)) {
if (query[key] === undefined || query[key] === null) delete query[key];
else if (typeof query[key] === "number") query[key] = (query[key] as number).toString();
else if (typeof query[key] === "boolean") query[key] = query[key] ? '1' : '0';
}
filteredQuery = query as Record<string, string>;
} else {
filteredQuery = query;
}
const queryStr = new URLSearchParams(filteredQuery).toString();
return `${absolute ? publicUrl : ''}${path}` + (queryStr.length > 0 ? '?' + queryStr : '');
}
export function getRouteParamRegExp(key: string, flags?: string): RegExp {
return new RegExp(`:${key}(\\(.+?\\))?\\??`, flags);
}

View File

@ -7,7 +7,7 @@ export class TimeUnit {
} }
} }
export default class Time { export class Time {
public static readonly UNITS = { public static readonly UNITS = {
MILLISECOND: { MILLISECOND: {
milliseconds: 1, milliseconds: 1,
@ -89,5 +89,4 @@ export default class Time {
return 'now'; return 'now';
} }
} }

View File

@ -1,11 +1,10 @@
import config from "config"; import config from "config";
import {Express, Router} from "express"; import {Express, Router} from "express";
import path from "path"; import path from "path";
import {ParsedUrlQueryInput} from "querystring";
import util from "util"; import util from "util";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import Controller, {RouteParams} from "../Controller.js"; import {QueryParams, route, RouteParams} from "../common/Routing.js";
import AssetCompiler from "../frontend/AssetCompiler.js"; import AssetCompiler from "../frontend/AssetCompiler.js";
import AssetPreCompiler from "../frontend/AssetPreCompiler.js"; import AssetPreCompiler from "../frontend/AssetPreCompiler.js";
import Globals from "../frontend/Globals.js"; import Globals from "../frontend/Globals.js";
@ -40,11 +39,11 @@ export default class FrontendToolsComponent extends ApplicationComponent {
public async init(): Promise<void> { public async init(): Promise<void> {
this.globals.set('route', ( this.globals.set('route', (
route: string, routeName: string,
params: RouteParams = [], params: RouteParams = [],
query: ParsedUrlQueryInput = {}, query: QueryParams = '',
absolute: boolean = false, absolute: boolean = false,
) => Controller.route(route, params, query, absolute)); ) => route(routeName, params, query, absolute));
this.globals.set('app_version', this.getApp().getVersion()); this.globals.set('app_version', this.getApp().getVersion());
this.globals.set('core_version', this.getApp().getCoreVersion()); this.globals.set('core_version', this.getApp().getCoreVersion());
this.globals.set('app', config.get('app')); this.globals.set('app', config.get('app'));

View File

@ -4,9 +4,8 @@ import nodemailer, {SentMessageInfo, Transporter} from "nodemailer";
import util from "util"; import util from "util";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import Controller from "../Controller.js"; import {route} from "../common/Routing.js";
import MailViewEngine from "../frontend/MailViewEngine.js"; import MailViewEngine from "../frontend/MailViewEngine.js";
import ViewEngine from "../frontend/ViewEngine.js";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
import Mail from "../mail/Mail.js"; import Mail from "../mail/Mail.js";
import MailError from "../mail/MailError.js"; import MailError from "../mail/MailError.js";
@ -91,7 +90,7 @@ export default class MailComponent extends ApplicationComponent {
locals.mail_subject = options.subject; locals.mail_subject = options.subject;
locals.mail_to = options.to; locals.mail_to = options.to;
locals.mail_link = config.get<string>('public_url') + locals.mail_link = config.get<string>('public_url') +
Controller.route('mail', [template.template], locals); route('mail', [template.template], locals);
Object.assign(locals, this.getApp().as(FrontendToolsComponent).getGlobals().get()); Object.assign(locals, this.getApp().as(FrontendToolsComponent).getGlobals().get());
// Log // Log

View File

@ -9,6 +9,7 @@ import requireFromString from "require-from-string";
import {compile, preprocess} from "svelte/compiler"; import {compile, preprocess} from "svelte/compiler";
import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess.js"; import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess.js";
import {publicUrl, routes} from "../common/Routing.js";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
import FileCache from "../utils/FileCache.js"; import FileCache from "../utils/FileCache.js";
import ViewEngine from "./ViewEngine.js"; import ViewEngine from "./ViewEngine.js";
@ -89,6 +90,9 @@ export default class SvelteViewEngine extends ViewEngine {
head: head, head: head,
html: html, html: html,
css: css, css: css,
routes: JSON.stringify(routes),
publicUrl: publicUrl,
}; };
return rawOutput.replace( return rawOutput.replace(
new RegExp(Object.keys(replaceMap).map(str => `%${str}%`).join('|'), 'g'), new RegExp(Object.keys(replaceMap).map(str => `%${str}%`).join('|'), 'g'),

View File

@ -1,4 +1,3 @@
import config from "config";
import {Request, Response} from "express"; import {Request, Response} from "express";
import {RequireAdminMiddleware, RequireAuthMiddleware} from "../auth/AuthComponent.js"; import {RequireAdminMiddleware, RequireAuthMiddleware} from "../auth/AuthComponent.js";
@ -6,6 +5,7 @@ import User from "../auth/models/User.js";
import UserApprovedComponent from "../auth/models/UserApprovedComponent.js"; import UserApprovedComponent from "../auth/models/UserApprovedComponent.js";
import UserEmail from "../auth/models/UserEmail.js"; import UserEmail from "../auth/models/UserEmail.js";
import UserNameComponent from "../auth/models/UserNameComponent.js"; import UserNameComponent from "../auth/models/UserNameComponent.js";
import {route} from "../common/Routing.js";
import MailComponent from "../components/MailComponent.js"; import MailComponent from "../components/MailComponent.js";
import Controller from "../Controller.js"; import Controller from "../Controller.js";
import ModelFactory from "../db/ModelFactory.js"; import ModelFactory from "../db/ModelFactory.js";
@ -24,7 +24,7 @@ export default class BackendController extends Controller {
super(); super();
if (User.isApprovalMode()) { if (User.isApprovalMode()) {
BackendController.registerMenuElement({ BackendController.registerMenuElement({
getLink: async () => Controller.route('accounts-approval'), getLink: async () => route('accounts-approval'),
getDisplayString: async () => { getDisplayString: async () => {
const pendingUsersCount = (await User.select() const pendingUsersCount = (await User.select()
.where('approved', false) .where('approved', false)
@ -81,12 +81,12 @@ export default class BackendController extends Controller {
if (email && email.email) { if (email && email.email) {
await this.getApp().as(MailComponent).sendMail(new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, { await this.getApp().as(MailComponent).sendMail(new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: true, approved: true,
link: config.get<string>('public_url') + Controller.route('auth'), link: route('auth', {}, {}, true),
}), email.email); }), email.email);
} }
req.flash('success', `Account successfully approved.`); req.flash('success', `Account successfully approved.`);
res.redirect(Controller.route('accounts-approval')); res.redirect(route('accounts-approval'));
} }
protected async postRejectAccount(req: Request, res: Response): Promise<void> { protected async postRejectAccount(req: Request, res: Response): Promise<void> {
@ -101,7 +101,7 @@ export default class BackendController extends Controller {
} }
req.flash('success', `Account successfully deleted.`); req.flash('success', `Account successfully deleted.`);
res.redirect(Controller.route('accounts-approval')); res.redirect(route('accounts-approval'));
} }
protected async accountRequest(req: Request): Promise<{ protected async accountRequest(req: Request): Promise<{
@ -122,7 +122,7 @@ export default class BackendController extends Controller {
export interface BackendMenuElement { export interface BackendMenuElement {
/** /**
* Returns the link of this menu element (usually using {@code Controller.route}) * Returns the link of this menu element (usually using {@code route})
*/ */
getLink(): Promise<string>; getLink(): Promise<string>;

View File

@ -1,6 +1,7 @@
import {Options} from "nodemailer/lib/mailer"; import {Options} from "nodemailer/lib/mailer";
import {ParsedUrlQueryInput} from "querystring"; import {ParsedUrlQueryInput} from "querystring";
import {QueryParams, QueryParamsRecord} from "../common/Routing.js";
import MailError from "./MailError.js"; import MailError from "./MailError.js";
import MailTemplate from "./MailTemplate.js"; import MailTemplate from "./MailTemplate.js";
@ -9,7 +10,7 @@ export default class Mail {
public constructor( public constructor(
private readonly template: MailTemplate, private readonly template: MailTemplate,
private readonly data: ParsedUrlQueryInput = {}, private readonly data: QueryParamsRecord = {},
) { ) {
this.options.subject = this.template.getSubject(data); this.options.subject = this.template.getSubject(data);
@ -30,7 +31,7 @@ export default class Mail {
return this.template; return this.template;
} }
public getData(): ParsedUrlQueryInput { public getData(): QueryParamsRecord {
return {...this.data}; return {...this.data};
} }

View File

@ -1,35 +0,0 @@
import Controller from "../src/Controller.js";
describe('Controller.route()', () => {
new class extends Controller {
public routes(): void {
const emptyHandler = () => {
//
};
this.get('/test/no-param', emptyHandler, 'test.no-param');
this.get('/test/no-param/', emptyHandler, 'test.no-param-slash');
this.get('/test/one-param/:param', emptyHandler, 'test.one-param');
this.get('/test/two-params/:param1/:param2', emptyHandler, 'test.two-params');
this.get('/test/paginated/:page([0-9]+)?', emptyHandler, 'test.paginated');
this.get('/test/slug/:slug([a-zA-Z0-9]+)?', emptyHandler, 'test.slug');
}
}().setupRoutes();
test('Empty params', () => {
expect(Controller.route('test.no-param')).toBe('/test/no-param');
expect(Controller.route('test.no-param-slash')).toBe('/test/no-param/');
expect(Controller.route('test.one-param')).toBe('/test/one-param/');
expect(Controller.route('test.two-params')).toBe('/test/two-params/');
expect(Controller.route('test.paginated')).toBe('/test/paginated/');
expect(Controller.route('test.slug')).toBe('/test/slug/');
});
test('Populated params', () => {
expect(Controller.route('test.no-param')).toBe('/test/no-param');
expect(Controller.route('test.no-param-slash')).toBe('/test/no-param/');
expect(Controller.route('test.one-param', {param: 'value'})).toBe('/test/one-param/value');
expect(Controller.route('test.two-params', {param1: 'value1', param2: 'value2'})).toBe('/test/two-params/value1/value2');
expect(Controller.route('test.paginated', {page: 5})).toBe('/test/paginated/5');
expect(Controller.route('test.slug', {slug: 'abc'})).toBe('/test/slug/abc');
});
});

View File

@ -0,0 +1,36 @@
import {route} from "../../src/common/Routing.js";
import Controller from "../../src/Controller.js";
describe('route()', () => {
new class extends Controller {
public routes(): void {
const emptyHandler = () => {
//
};
this.get('/test/no-param', emptyHandler, 'test.no-param');
this.get('/test/no-param/', emptyHandler, 'test.no-param-slash');
this.get('/test/one-param/:param', emptyHandler, 'test.one-param');
this.get('/test/two-params/:param1/:param2', emptyHandler, 'test.two-params');
this.get('/test/paginated/:page([0-9]+)?', emptyHandler, 'test.paginated');
this.get('/test/slug/:slug([a-zA-Z0-9]+)?', emptyHandler, 'test.slug');
}
}().setupRoutes();
test('Empty params', () => {
expect(route('test.no-param')).toBe('/test/no-param');
expect(route('test.no-param-slash')).toBe('/test/no-param/');
expect(route('test.one-param')).toBe('/test/one-param/');
expect(route('test.two-params')).toBe('/test/two-params/');
expect(route('test.paginated')).toBe('/test/paginated/');
expect(route('test.slug')).toBe('/test/slug/');
});
test('Populated params', () => {
expect(route('test.no-param')).toBe('/test/no-param');
expect(route('test.no-param-slash')).toBe('/test/no-param/');
expect(route('test.one-param', {param: 'value'})).toBe('/test/one-param/value');
expect(route('test.two-params', {param1: 'value1', param2: 'value2'})).toBe('/test/two-params/value1/value2');
expect(route('test.paginated', {page: 5})).toBe('/test/paginated/5');
expect(route('test.slug', {slug: 'abc'})).toBe('/test/slug/abc');
});
});

View File

@ -21,7 +21,8 @@
"src/types" "src/types"
], ],
"lib": [ "lib": [
"es2020" "es2020",
"dom"
], ],
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,