Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2021-03-30 12:12:54 +02:00
commit 55d2040dc3
39 changed files with 1631 additions and 765 deletions

View File

@ -86,6 +86,7 @@
}, },
"ignorePatterns": [ "ignorePatterns": [
"jest.config.js", "jest.config.js",
"scripts/**/*",
"dist/**/*", "dist/**/*",
"config/**/*" "config/**/*"
], ],

View File

@ -50,5 +50,8 @@
magic_link: { magic_link: {
validity_period: 20, validity_period: 20,
}, },
approval_mode: false, auth: {
approval_mode: false, // Registered accounts need to be approved by an administrator
name_change_wait_period: 2592000000, // 30 days
},
} }

View File

@ -1,8 +1,8 @@
{ {
"name": "swaf", "name": "swaf",
"version": "0.23.6", "version": "0.23.7",
"description": "Structure Web Application Framework.", "description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/arisu/swaf", "repository": "https://eternae.ink/ashpie/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",
"license": "MIT", "license": "MIT",
"readme": "README.md", "readme": "README.md",
@ -14,12 +14,12 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"scripts": { "scripts": {
"test": "jest --verbose --runInBand", "test": "jest --verbose --runInBand",
"clean": "(test ! -d dist || rm -r dist)", "clean": "node scripts/clean.js",
"prepareSources": "cp package.json src/", "prepare-sources": "node scripts/prepare-sources.js",
"compile": "yarn clean && tsc", "compile": "yarn clean && tsc",
"build": "yarn prepareSources && yarn compile && cp -r yarn.lock README.md config/ views/ dist/ && mkdir dist/types && cp src/types/* dist/types/", "build": "yarn prepare-sources && yarn compile && node scripts/dist.js",
"dev": "yarn prepareSources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"maildev\"", "dev": "yarn prepare-sources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"maildev\"",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint": "eslint .",
"release": "yarn build && yarn lint && yarn test && cd dist && yarn publish" "release": "yarn build && yarn lint && yarn test && cd dist && yarn publish"
}, },
"devDependencies": { "devDependencies": {
@ -46,7 +46,7 @@
"@types/ws": "^7.2.4", "@types/ws": "^7.2.4",
"@typescript-eslint/eslint-plugin": "^4.2.0", "@typescript-eslint/eslint-plugin": "^4.2.0",
"@typescript-eslint/parser": "^4.2.0", "@typescript-eslint/parser": "^4.2.0",
"concurrently": "^5.3.0", "concurrently": "^6.0.0",
"eslint": "^7.9.0", "eslint": "^7.9.0",
"jest": "^26.1.0", "jest": "^26.1.0",
"maildev": "^1.1.0", "maildev": "^1.1.0",

10
scripts/clean.js Normal file
View File

@ -0,0 +1,10 @@
const fs = require('fs');
[
'dist',
].forEach(file => {
if (fs.existsSync(file)) {
console.log('Cleaning', file, '...');
fs.rmSync(file, {recursive: true});
}
});

34
scripts/dist.js Normal file
View File

@ -0,0 +1,34 @@
const fs = require('fs');
const path = require('path');
function copyRecursively(file, destination) {
const target = path.join(destination, path.basename(file));
if (fs.statSync(file).isDirectory()) {
console.log('mkdir', target);
fs.mkdirSync(target, {recursive: true});
fs.readdirSync(file).forEach(f => {
copyRecursively(path.join(file, f), target);
});
} else {
console.log('> cp ', target);
fs.copyFileSync(file, target);
}
}
[
'yarn.lock',
'README.md',
'config/',
'views/',
].forEach(file => {
copyRecursively(file, 'dist');
});
fs.mkdirSync('dist/types', {recursive: true});
fs.readdirSync('src/types').forEach(file => {
copyRecursively(path.join('src/types', file), 'dist/types');
});

View File

@ -0,0 +1,4 @@
const fs = require('fs');
const path = require('path');
fs.copyFileSync('package.json', path.join('src', 'package.json'));

View File

@ -21,6 +21,7 @@ import TemplateError = lib.TemplateError;
export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> { export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
private readonly version: string; private readonly version: string;
private coreVersion: string = 'unknown';
private readonly ignoreCommandLine: boolean; private readonly ignoreCommandLine: boolean;
private readonly controllers: Controller[] = []; private readonly controllers: Controller[] = [];
private readonly webSocketListeners: { [p: string]: WebSocketListener<Application> } = {}; private readonly webSocketListeners: { [p: string]: WebSocketListener<Application> } = {};
@ -28,6 +29,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
private cacheProvider?: CacheProvider; private cacheProvider?: CacheProvider;
private ready: boolean = false; private ready: boolean = false;
private started: boolean = false;
private busy: boolean = false;
protected constructor(version: string, ignoreCommandLine: boolean = false) { protected constructor(version: string, ignoreCommandLine: boolean = false) {
this.version = version; this.version = version;
@ -58,23 +61,46 @@ export default abstract class Application implements Extendable<ApplicationCompo
} }
public async start(): Promise<void> { public async start(): Promise<void> {
logger.info(`${config.get('app.name')} v${this.version} - hi`); if (this.started) throw new Error('Application already started');
process.once('SIGINT', () => { if (this.busy) throw new Error('Application busy');
this.busy = true;
// Load core version
const file = this.isInNodeModules() ?
path.join(__dirname, '../../package.json') :
path.join(__dirname, '../package.json');
try {
this.coreVersion = JSON.parse(fs.readFileSync(file).toString()).version;
} catch (e) {
logger.warn('Couldn\'t determine coreVersion.', e);
}
logger.info(`${config.get('app.name')} v${this.version} | swaf v${this.coreVersion}`);
// Catch interrupt signals
const exitHandler = () => {
this.stop().catch(console.error); this.stop().catch(console.error);
}); };
process.once('exit', exitHandler);
process.once('SIGINT', exitHandler);
process.once('SIGUSR1', exitHandler);
process.once('SIGUSR2', exitHandler);
process.once('uncaughtException', exitHandler);
// Register migrations // Register migrations
MysqlConnectionManager.registerMigrations(this.getMigrations()); MysqlConnectionManager.registerMigrations(this.getMigrations());
// Process command line
if (!this.ignoreCommandLine && await this.processCommandLine()) {
await this.stop();
return;
}
// Register all components and alike // Register all components and alike
await this.init(); await this.init();
// Process command line
if (!this.ignoreCommandLine && await this.processCommandLine()) {
this.started = true;
this.busy = false;
return;
}
// Security // Security
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
await this.checkSecuritySettings(); await this.checkSecuritySettings();
@ -189,26 +215,54 @@ export default abstract class Application implements Extendable<ApplicationCompo
this.routes(initRouter, handleRouter); this.routes(initRouter, handleRouter);
this.ready = true; this.ready = true;
this.started = true;
this.busy = false;
} }
protected async processCommandLine(): Promise<boolean> { protected async processCommandLine(): Promise<boolean> {
const args = process.argv; const args = process.argv;
// Flags
const flags = {
verbose: false,
fullHttpRequests: false,
};
let mainCommand: string | null = null;
const mainCommandArgs: string[] = [];
for (let i = 2; i < args.length; i++) { for (let i = 2; i < args.length; i++) {
switch (args[i]) { switch (args[i]) {
case '--verbose': case '--verbose':
logger.setSettings({minLevel: "trace"}); flags.verbose = true;
break; break;
case '--full-http-requests': case '--full-http-requests':
LogRequestsComponent.logFullHttpRequests(); flags.fullHttpRequests = true;
break; break;
case 'migration': case 'migration':
await MysqlConnectionManager.migrationCommand(args.slice(i + 1)); if (mainCommand === null) mainCommand = args[i];
return true; else throw new Error(`Only one main command can be used at once (${mainCommand},${args[i]})`);
break;
default: default:
logger.warn('Unrecognized argument', args[i]); if (mainCommand) mainCommandArgs.push(args[i]);
else logger.fatal('Unrecognized argument', args[i]);
return true; return true;
} }
} }
if (flags.verbose) logger.setSettings({minLevel: "trace"});
if (flags.fullHttpRequests) LogRequestsComponent.logFullHttpRequests();
if (mainCommand) {
switch (mainCommand) {
case 'migration':
await MysqlConnectionManager.migrationCommand(mainCommandArgs);
await this.stop();
break;
default:
logger.fatal('Unimplemented main command', mainCommand);
break;
}
return true;
}
return false; return false;
} }
@ -233,13 +287,18 @@ export default abstract class Application implements Extendable<ApplicationCompo
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
if (this.started && !this.busy) {
this.busy = true;
logger.info('Stopping application...'); logger.info('Stopping application...');
for (const component of this.components) { for (const component of this.components) {
await component.stop?.(); await component.stop?.();
} }
logger.info(`${this.constructor.name} v${this.version} - bye`); logger.info(`${this.constructor.name} stopped properly.`);
this.started = false;
this.busy = false;
}
} }
private routes(initRouter: Router, handleRouter: Router) { private routes(initRouter: Router, handleRouter: Router) {
@ -264,14 +323,6 @@ export default abstract class Application implements Extendable<ApplicationCompo
}); });
} }
public isReady(): boolean {
return this.ready;
}
public getVersion(): string {
return this.version;
}
public getWebSocketListeners(): { [p: string]: WebSocketListener<Application> } { public getWebSocketListeners(): { [p: string]: WebSocketListener<Application> } {
return this.webSocketListeners; return this.webSocketListeners;
} }
@ -292,4 +343,20 @@ export default abstract class Application implements Extendable<ApplicationCompo
Object.values(this.webSocketListeners).find(listener => listener.constructor === type); Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
return module ? module as C : null; return module ? module as C : null;
} }
public isInNodeModules(): boolean {
return fs.existsSync(path.join(__dirname, '../../package.json'));
}
public isReady(): boolean {
return this.ready;
}
public getVersion(): string {
return this.version;
}
public getCoreVersion(): string {
return this.coreVersion;
}
} }

View File

@ -9,6 +9,9 @@ import Middleware, {MiddlewareType} from "./Middleware";
import Application from "./Application"; import Application from "./Application";
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 } = {}; private static readonly routes: { [p: string]: string | undefined } = {};
public static route( public static route(
@ -20,11 +23,12 @@ export default abstract class Controller {
let path = this.routes[route]; let path = this.routes[route];
if (path === undefined) throw new Error(`Unknown route for name ${route}.`); if (path === undefined) throw new Error(`Unknown route for name ${route}.`);
const regExp = this.getRouteParamRegExp('[a-zA-Z0-9_-]+', 'g');
if (typeof params === 'string' || typeof params === 'number') { if (typeof params === 'string' || typeof params === 'number') {
path = path.replace(/:[a-zA-Z_-]+\??/g, '' + params); path = path.replace(regExp, '' + params);
} else if (Array.isArray(params)) { } else if (Array.isArray(params)) {
let i = 0; let i = 0;
for (const match of path.matchAll(/:[a-zA-Z_-]+(\(.*\))?\??/g)) { for (const match of path.matchAll(regExp)) {
if (match.length > 0) { if (match.length > 0) {
path = path.replace(match[0], typeof params[i] !== 'undefined' ? params[i] : ''); path = path.replace(match[0], typeof params[i] !== 'undefined' ? params[i] : '');
} }
@ -33,7 +37,7 @@ export default abstract class Controller {
path = path.replace(/\/+/g, '/'); path = path.replace(/\/+/g, '/');
} else { } else {
for (const key of Object.keys(params)) { for (const key of Object.keys(params)) {
path = path.replace(new RegExp(`:${key}\\??`), params[key]); path = path.replace(this.getRouteParamRegExp(key), params[key].toString());
} }
} }
@ -41,6 +45,10 @@ export default abstract class Controller {
return `${absolute ? config.get<string>('public_url') : ''}${path}` + (queryStr.length > 0 ? '?' + queryStr : ''); 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;
@ -194,4 +202,4 @@ export default abstract class Controller {
} }
} }
export type RouteParams = { [p: string]: string } | string[] | string | number; export type RouteParams = { [p: string]: string | number } | string[] | string | number;

View File

@ -1,10 +1,10 @@
import {IncomingForm} from "formidable"; import Formidable from "formidable";
import Middleware from "./Middleware"; import Middleware from "./Middleware";
import {NextFunction, Request, Response} from "express"; import {NextFunction, Request, Response} from "express";
import {FileError, ValidationBag} from "./db/Validator"; import {FileError, ValidationBag} from "./db/Validator";
export default abstract class FileUploadMiddleware extends Middleware { export default abstract class FileUploadMiddleware extends Middleware {
protected abstract makeForm(): IncomingForm; protected abstract makeForm(): Formidable;
protected abstract getDefaultField(): string; protected abstract getDefaultField(): string;

View File

@ -33,7 +33,7 @@ export const preventContextCorruptionMiddleware = (delegate: RequestHandler): Re
) => { ) => {
const data = requestIdStorage.getStore() as string; const data = requestIdStorage.getStore() as string;
delegate(req, res, (err?: Error | 'router') => { delegate(req, res, (err?: unknown | 'router') => {
requestIdStorage.enterWith(data); requestIdStorage.enterWith(data);
next(err); next(err);
}); });

View File

@ -22,3 +22,8 @@ export const ADD_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'add_email', 'add_email',
(data) => `Add ${data.email} address to your ${config.get<string>('app.name')} account.`, (data) => `Add ${data.email} address to your ${config.get<string>('app.name')} account.`,
); );
export const REMOVE_PASSWORD_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'remove_password',
() => `Remove password from your ${config.get<string>('app.name')} account.`,
);

View File

@ -1,16 +1,18 @@
import Model from "./db/Model"; import {WrappingError} from "./Utils";
export default class Pagination<T extends Model> { export default class Pagination {
private readonly models: T[];
public readonly page: number; public readonly page: number;
public readonly perPage: number; public readonly perPage: number;
public readonly totalCount: number; public readonly totalCount: number;
public constructor(models: T[], page: number, perPage: number, totalCount: number) { public constructor(page: number, perPage: number, totalCount: number) {
this.models = models;
this.page = page; this.page = page;
this.perPage = perPage; this.perPage = perPage;
this.totalCount = totalCount; this.totalCount = totalCount;
if (this.page < 1 || this.page > this.lastPage) {
throw new PageNotFoundError(this.page);
}
} }
public hasPrevious(): boolean { public hasPrevious(): boolean {
@ -18,7 +20,69 @@ export default class Pagination<T extends Model> {
} }
public hasNext(): boolean { public hasNext(): boolean {
return this.models.length >= this.perPage && this.page * this.perPage < this.totalCount; return this.page < this.lastPage;
} }
public get lastPage(): number {
return Math.ceil(this.totalCount / this.perPage);
}
public previousPages(contextSize: number): number[] {
const pages = [];
let i = 1;
// Leftmost context
while (i < this.page && i <= contextSize) {
pages.push(i);
i++;
}
// Ellipsis
if (i < this.page - contextSize) {
pages.push(-1);
}
// Middle left context
i = Math.max(i, this.page - contextSize);
while (i < this.page) {
pages.push(i);
i++;
}
return pages;
}
public nextPages(contextSize: number): number[] {
const pages = [];
let i = this.page + 1;
// Middle right context
while (i <= this.lastPage && i <= this.page + contextSize) {
pages.push(i);
i++;
}
// Ellipsis
if (this.page + contextSize + 1 < this.lastPage - contextSize + 1) {
pages.push(-1);
}
// Rightmost context
i = Math.max(i, this.lastPage - contextSize + 1);
while (i <= this.lastPage) {
pages.push(i);
i++;
}
return pages;
}
}
export class PageNotFoundError extends WrappingError {
public constructor(
public readonly page: number,
) {
super(`Page ${page} not found.`);
}
} }

View File

@ -31,6 +31,7 @@ import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagic
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration"; import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration";
import packageJson = require('./package.json'); import packageJson = require('./package.json');
import PreviousUrlComponent from "./components/PreviousUrlComponent"; import PreviousUrlComponent from "./components/PreviousUrlComponent";
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration";
export const MIGRATIONS = [ export const MIGRATIONS = [
CreateMigrationsTable, CreateMigrationsTable,
@ -40,14 +41,15 @@ export const MIGRATIONS = [
AddNameToUsersMigration, AddNameToUsersMigration,
MakeMagicLinksSessionNotUniqueMigration, MakeMagicLinksSessionNotUniqueMigration,
AddUsedToMagicLinksMigration, AddUsedToMagicLinksMigration,
AddNameChangedAtToUsersMigration,
]; ];
export default class TestApp extends Application { export default class TestApp extends Application {
private readonly addr: string; private readonly addr: string;
private readonly port: number; private readonly port: number;
public constructor(addr: string, port: number) { public constructor(addr: string, port: number, ignoreCommandLine: boolean = false) {
super(packageJson.version, true); super(packageJson.version, ignoreCommandLine);
this.addr = addr; this.addr = addr;
this.port = port; this.port = port;
} }

93
src/Time.ts Normal file
View File

@ -0,0 +1,93 @@
export class TimeUnit {
public constructor(
public readonly milliseconds: number,
public readonly longName: string,
public readonly shortName: string,
) {
}
}
export default class Time {
public static readonly UNITS = {
MILLISECOND: {
milliseconds: 1,
longName: 'millisecond',
shortName: 'ms',
},
SECOND: {
milliseconds: 1000,
longName: 'second',
shortName: 's',
},
MINUTE: {
milliseconds: 60 * 1000,
longName: 'minute',
shortName: 'm',
},
HOUR: {
milliseconds: 60 * 60 * 1000,
longName: 'hour',
shortName: 'h',
},
DAY: {
milliseconds: 24 * 60 * 60 * 1000,
longName: 'day',
shortName: 'd',
},
MONTH: {
milliseconds: 30 * 24 * 60 * 60 * 1000,
longName: 'month',
shortName: 'M',
},
YEAR: {
milliseconds: 365 * 24 * 60 * 60 * 1000,
longName: 'year',
shortName: 'y',
},
};
public static humanizeTimeSince(
since: Date,
short?: boolean,
skipOneUnitNumber?: boolean,
units?: TimeUnit[],
): string {
return this.humanizeDuration(Date.now() - since.getTime(), short, skipOneUnitNumber, units);
}
public static humanizeTimeTo(
to: Date,
short?: boolean,
skipOneUnitNumber?: boolean,
units?: TimeUnit[],
): string {
return this.humanizeDuration(to.getTime() - Date.now(), short, skipOneUnitNumber, units);
}
public static humanizeDuration(
duration: number,
short: boolean = false,
skipOneUnitNumber: boolean = false,
units: TimeUnit[] = [
this.UNITS.SECOND,
this.UNITS.MINUTE,
this.UNITS.HOUR,
this.UNITS.DAY,
this.UNITS.MONTH,
this.UNITS.YEAR,
],
): string {
for (let i = units.length - 1; i > 0; i--) {
if (duration >= units[i - 1].milliseconds && duration < units[i].milliseconds) {
const amount = Math.floor(duration / units[i - 1].milliseconds);
const unit = short ?
units[i - 1].shortName :
' ' + units[i - 1].longName + (amount > 1 ? 's' : '');
return (amount > 1 || !skipOneUnitNumber ? amount : '') + unit;
}
}
return 'now';
}
}

View File

@ -10,15 +10,18 @@ import ModelFactory from "../db/ModelFactory";
import UserEmail from "./models/UserEmail"; import UserEmail from "./models/UserEmail";
import MagicLinkController from "./magic_link/MagicLinkController"; import MagicLinkController from "./magic_link/MagicLinkController";
import {MailTemplate} from "../mail/Mail"; import {MailTemplate} from "../mail/Mail";
import {ADD_EMAIL_MAIL_TEMPLATE} from "../Mails"; import {ADD_EMAIL_MAIL_TEMPLATE, REMOVE_PASSWORD_MAIL_TEMPLATE} from "../Mails";
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType"; import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType";
import UserNameComponent from "./models/UserNameComponent";
import Time from "../Time";
export default class AccountController extends Controller { export default class AccountController extends Controller {
private readonly addEmailMailTemplate: MailTemplate;
public constructor(addEmailMailTemplate: MailTemplate = ADD_EMAIL_MAIL_TEMPLATE) { public constructor(
private readonly addEmailMailTemplate: MailTemplate = ADD_EMAIL_MAIL_TEMPLATE,
private readonly removePasswordMailTemplate: MailTemplate = REMOVE_PASSWORD_MAIL_TEMPLATE,
) {
super(); super();
this.addEmailMailTemplate = addEmailMailTemplate;
} }
public getRoutesPrefix(): string { public getRoutesPrefix(): string {
@ -28,8 +31,13 @@ export default class AccountController extends Controller {
public routes(): void { public routes(): void {
this.get('/', this.getAccount, 'account', RequireAuthMiddleware); this.get('/', this.getAccount, 'account', RequireAuthMiddleware);
if (ModelFactory.get(User).hasComponent(UserNameComponent)) {
this.post('/change-name', this.postChangeName, 'change-name', RequireAuthMiddleware);
}
if (ModelFactory.get(User).hasComponent(UserPasswordComponent)) { if (ModelFactory.get(User).hasComponent(UserPasswordComponent)) {
this.post('/change-password', this.postChangePassword, 'change-password', RequireAuthMiddleware); this.post('/change-password', this.postChangePassword, 'change-password', RequireAuthMiddleware);
this.post('/remove-password', this.postRemovePassword, 'remove-password', RequireAuthMiddleware);
} }
this.post('/add-email', this.addEmail, 'add-email', RequireAuthMiddleware); this.post('/add-email', this.addEmail, 'add-email', RequireAuthMiddleware);
@ -40,14 +48,48 @@ export default class AccountController extends Controller {
protected async getAccount(req: Request, res: Response): Promise<void> { protected async getAccount(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser(); const user = req.as(RequireAuthMiddleware).getUser();
res.render('auth/account', { const passwordComponent = user.asOptional(UserPasswordComponent);
const nameComponent = user.asOptional(UserNameComponent);
const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period');
const nameChangedAt = nameComponent?.getNameChangedAt()?.getTime() || Date.now();
const nameChangeRemainingTime = new Date(nameChangedAt + nameChangeWaitPeriod);
res.render('auth/account/account', {
main_email: await user.mainEmail.get(), main_email: await user.mainEmail.get(),
emails: await user.emails.get(), emails: await user.emails.get(),
display_email_warning: config.get('app.display_email_warning'), display_email_warning: config.get('app.display_email_warning'),
has_password: user.asOptional(UserPasswordComponent)?.hasPassword(), has_password_component: !!passwordComponent,
has_password: passwordComponent?.hasPassword(),
has_name_component: !!nameComponent,
name_change_wait_period: Time.humanizeDuration(nameChangeWaitPeriod, false, true),
can_change_name: nameComponent?.canChangeName(),
can_change_name_in: Time.humanizeTimeTo(nameChangeRemainingTime),
}); });
} }
protected async postChangeName(req: Request, res: Response): Promise<void> {
await Validator.validate({
'name': new Validator().defined(),
}, req.body);
const user = req.as(RequireAuthMiddleware).getUser();
const userNameComponent = user.as(UserNameComponent);
if (!userNameComponent.setName(req.body.name)) {
const nameChangedAt = userNameComponent.getNameChangedAt()?.getTime() || Date.now();
const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period');
req.flash('error', `Your can't change your name until ${new Date(nameChangedAt + nameChangeWaitPeriod)}.`);
res.redirect(Controller.route('account'));
return;
}
await user.save();
req.flash('success', `Your name was successfully changed to ${req.body.name}.`);
res.redirect(Controller.route('account'));
}
protected async postChangePassword(req: Request, res: Response): Promise<void> { protected async postChangePassword(req: Request, res: Response): Promise<void> {
const validationMap = { const validationMap = {
'new_password': new Validator().defined(), 'new_password': new Validator().defined(),
@ -69,6 +111,31 @@ export default class AccountController extends Controller {
res.redirect(Controller.route('account')); res.redirect(Controller.route('account'));
} }
protected async postRemovePassword(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const mainEmail = await user.mainEmail.get();
if (!mainEmail || !mainEmail.email) {
req.flash('error', 'You can\'t remove your password without adding an email address first.');
res.redirect(Controller.route('account'));
return;
}
await MagicLinkController.sendMagicLink(
this.getApp(),
req.getSession().id,
AuthMagicLinkActionType.REMOVE_PASSWORD,
Controller.route('account'),
mainEmail.email,
this.removePasswordMailTemplate,
{},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: Controller.route('account'),
}));
}
protected async addEmail(req: Request, res: Response): Promise<void> { protected async addEmail(req: Request, res: Response): Promise<void> {
await Validator.validate({ await Validator.validate({

View File

@ -141,7 +141,7 @@ export default class AuthGuard {
if (!user.isApproved()) { if (!user.isApproved()) {
await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, { await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: user.asOptional(UserNameComponent)?.name || 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: config.get<string>('public_url') + Controller.route('accounts-approval'),

View File

@ -2,4 +2,5 @@ export default {
LOGIN: 'login', LOGIN: 'login',
REGISTER: 'register', REGISTER: 'register',
ADD_EMAIL: 'add_email', ADD_EMAIL: 'add_email',
REMOVE_PASSWORD: 'remove_password',
}; };

View File

@ -18,6 +18,7 @@ import {QueryVariable} from "../../db/MysqlConnectionManager";
import UserNameComponent from "../models/UserNameComponent"; import UserNameComponent from "../models/UserNameComponent";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent"; import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
import {logger} from "../../Logger"; import {logger} from "../../Logger";
import UserPasswordComponent from "../password/UserPasswordComponent";
export default class MagicLinkController<A extends Application> extends Controller { export default class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink( public static async sendMagicLink(
@ -64,7 +65,12 @@ export default class MagicLinkController<A extends Application> extends Controll
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister( return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
session, magicLink, !!session.wantsSessionPersistence, undefined, async (connection, user) => { session, magicLink, !!session.wantsSessionPersistence, undefined, async (connection, user) => {
const userNameComponent = user.asOptional(UserNameComponent); const userNameComponent = user.asOptional(UserNameComponent);
if (userNameComponent) userNameComponent.name = magicLink.as(MagicLinkUserNameComponent).username; const magicLinkUserNameComponent = magicLink.asOptional(MagicLinkUserNameComponent);
if (userNameComponent && magicLinkUserNameComponent?.username) {
userNameComponent.setName(magicLinkUserNameComponent.username);
}
return []; return [];
}, async (connection, user) => { }, async (connection, user) => {
const callbacks: RegisterCallback[] = []; const callbacks: RegisterCallback[] = [];
@ -191,7 +197,7 @@ export default class MagicLinkController<A extends Application> extends Controll
if (!res.headersSent && user) { if (!res.headersSent && user) {
// Auth success // Auth success
const name = user.asOptional(UserNameComponent)?.name; 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() || Controller.route('home'));
} }
@ -233,6 +239,29 @@ export default class MagicLinkController<A extends Application> extends Controll
res.redirect(Controller.route('account')); res.redirect(Controller.route('account'));
break; break;
} }
case AuthMagicLinkActionType.REMOVE_PASSWORD: {
const session = req.getSessionOptional();
if (!session || magicLink.session_id !== session.id) throw new BadOwnerMagicLink();
await magicLink.delete();
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
const proofs = await authGuard.getProofsForSession(session);
const user = await proofs[0]?.getResource();
if (!user) return;
const passwordComponent = user.asOptional(UserPasswordComponent);
if (passwordComponent) {
passwordComponent.removePassword();
await user.save();
}
req.flash('success', `Password successfully removed.`);
res.redirect(Controller.route('account'));
break;
}
default: default:
logger.warn('Unknown magic link action type ' + magicLink.action_type); logger.warn('Unknown magic link action type ' + magicLink.action_type);
break; break;

View File

@ -0,0 +1,12 @@
import Migration from "../../db/Migration";
export default class AddNameChangedAtToUsersMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN name_changed_at DATETIME`);
}
public async rollback(): Promise<void> {
await this.query('ALTER TABLE users DROP COLUMN IF EXISTS name_changed_at');
}
}

View File

@ -9,7 +9,7 @@ import UserNameComponent from "./UserNameComponent";
export default class User extends Model { export default class User extends Model {
public static isApprovalMode(): boolean { public static isApprovalMode(): boolean {
return config.get<boolean>('approval_mode') && return config.get<boolean>('auth.approval_mode') &&
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration); MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration);
} }
@ -42,10 +42,10 @@ export default class User extends Model {
protected getPersonalInfoFields(): { name: string, value: string }[] { protected getPersonalInfoFields(): { name: string, value: string }[] {
const fields: { name: string, value: string }[] = []; const fields: { name: string, value: string }[] = [];
const nameComponent = this.asOptional(UserNameComponent); const nameComponent = this.asOptional(UserNameComponent);
if (nameComponent && nameComponent.name) { if (nameComponent && nameComponent.hasName()) {
fields.push({ fields.push({
name: 'Name', name: 'Name',
value: nameComponent.name, value: nameComponent.getName(),
}); });
} }
return fields; return fields;

View File

@ -1,12 +1,44 @@
import ModelComponent from "../../db/ModelComponent"; import ModelComponent from "../../db/ModelComponent";
import User from "../models/User"; import User from "../models/User";
import config from "config";
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/; export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
export default class UserNameComponent extends ModelComponent<User> { export default class UserNameComponent extends ModelComponent<User> {
public name?: string = undefined; private name?: string = undefined;
private name_changed_at?: Date | null = undefined;
public init(): void { public init(): void {
this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model); this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model);
} }
public hasName(): boolean {
return !!this.name;
}
public getName(): string {
if (!this.name) throw new Error('User has no name.');
return this.name;
}
public setName(newName: string): boolean {
if (!this.canChangeName()) return false;
this.name = newName;
this.name_changed_at = new Date();
return true;
}
public getNameChangedAt(): Date | null {
return this.name_changed_at || null;
}
public forgetNameChangeDate(): void {
this.name_changed_at = null;
}
public canChangeName(): boolean {
return !this.name_changed_at ||
Date.now() - this.name_changed_at.getTime() >= config.get<number>('auth.name_change_wait_period');
}
} }

View File

@ -113,7 +113,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
await user.as(UserPasswordComponent).setPassword(req.body.password); await user.as(UserPasswordComponent).setPassword(req.body.password);
// Username // Username
user.as(UserNameComponent).name = req.body.identifier; user.as(UserNameComponent).setName(req.body.identifier);
return callbacks; return callbacks;
}, async (connection, user) => { }, async (connection, user) => {
@ -132,7 +132,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).name}.`); req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).getName()}.`);
res.redirect(req.getIntendedUrl() || Controller.route('home')); res.redirect(req.getIntendedUrl() || Controller.route('home'));
} }

View File

@ -6,7 +6,7 @@ import Validator from "../../db/Validator";
export default class UserPasswordComponent extends ModelComponent<User> { export default class UserPasswordComponent extends ModelComponent<User> {
public static readonly PASSWORD_MIN_LENGTH = 12; public static readonly PASSWORD_MIN_LENGTH = 12;
private password?: string = undefined; private password?: string | null = undefined;
public init(): void { public init(): void {
this.setValidation('password').acceptUndefined().maxLength(128); this.setValidation('password').acceptUndefined().maxLength(128);
@ -36,4 +36,8 @@ export default class UserPasswordComponent extends ModelComponent<User> {
public hasPassword(): boolean { public hasPassword(): boolean {
return typeof this.password === 'string'; return typeof this.password === 'string';
} }
public removePassword(): void {
this.password = null;
}
} }

View File

@ -7,8 +7,6 @@ import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring"; import {ParsedUrlQueryInput} from "querystring";
import * as util from "util"; import * as util from "util";
import * as path from "path"; import * as path from "path";
import * as fs from "fs";
import {logger} from "../Logger";
import Middleware from "../Middleware"; import Middleware from "../Middleware";
export default class NunjucksComponent extends ApplicationComponent { export default class NunjucksComponent extends ApplicationComponent {
@ -21,17 +19,6 @@ export default class NunjucksComponent extends ApplicationComponent {
} }
public async start(app: Express): Promise<void> { public async start(app: Express): Promise<void> {
let coreVersion = 'unknown';
const file = fs.existsSync(path.join(__dirname, '../../package.json')) ?
path.join(__dirname, '../../package.json') :
path.join(__dirname, '../package.json');
try {
coreVersion = JSON.parse(fs.readFileSync(file).toString()).version;
} catch (e) {
logger.warn('Couldn\'t determine coreVersion.', e);
}
const opts = { const opts = {
autoescape: true, autoescape: true,
noCache: !config.get('view.cache'), noCache: !config.get('view.cache'),
@ -51,7 +38,7 @@ export default class NunjucksComponent extends ApplicationComponent {
return Controller.route(route, params, query, absolute); return Controller.route(route, params, query, absolute);
}) })
.addGlobal('app_version', this.getApp().getVersion()) .addGlobal('app_version', this.getApp().getVersion())
.addGlobal('core_version', coreVersion) .addGlobal('core_version', this.getApp().getCoreVersion())
.addGlobal('querystring', querystring) .addGlobal('querystring', querystring)
.addGlobal('app', config.get('app')) .addGlobal('app', config.get('app'))

View File

@ -2,6 +2,7 @@ import ApplicationComponent from "../ApplicationComponent";
import express, {Router} from "express"; import express, {Router} from "express";
import {PathParams} from "express-serve-static-core"; import {PathParams} from "express-serve-static-core";
import * as path from "path"; import * as path from "path";
import {logger} from "../Logger";
export default class ServeStaticDirectoryComponent extends ApplicationComponent { export default class ServeStaticDirectoryComponent extends ApplicationComponent {
private readonly root: string; private readonly root: string;
@ -9,16 +10,20 @@ export default class ServeStaticDirectoryComponent extends ApplicationComponent
public constructor(root: string, routePath?: PathParams) { public constructor(root: string, routePath?: PathParams) {
super(); super();
this.root = path.join(__dirname, '../../../', root); this.root = root;
this.path = routePath; this.path = routePath;
} }
public async init(router: Router): Promise<void> { public async init(router: Router): Promise<void> {
const resolvedRoot = path.join(__dirname, this.getApp().isInNodeModules() ? '../../../' : '../../', this.root);
if (this.path) { if (this.path) {
router.use(this.path, express.static(this.root, {maxAge: 1000 * 3600 * 72})); router.use(this.path, express.static(resolvedRoot, {maxAge: 1000 * 3600 * 72}));
} else { } else {
router.use(express.static(this.root, {maxAge: 1000 * 3600 * 72})); router.use(express.static(resolvedRoot, {maxAge: 1000 * 3600 * 72}));
} }
logger.info('Serving static files in', resolvedRoot, ' on ', this.path || '/');
} }
} }

View File

@ -2,6 +2,8 @@ import ModelComponent from "./ModelComponent";
import Model, {ModelType} from "./Model"; import Model, {ModelType} from "./Model";
import ModelQuery, {ModelQueryResult, QueryFields} from "./ModelQuery"; import ModelQuery, {ModelQueryResult, QueryFields} from "./ModelQuery";
import {Request} from "express"; import {Request} from "express";
import {PageNotFoundError} from "../Pagination";
import {NotFoundHttpError} from "../HttpError";
export default class ModelFactory<M extends Model> { export default class ModelFactory<M extends Model> {
private static readonly factories: { [modelType: string]: ModelFactory<Model> | undefined } = {}; private static readonly factories: { [modelType: string]: ModelFactory<Model> | undefined } = {};
@ -86,16 +88,25 @@ export default class ModelFactory<M extends Model> {
return await query.first(); return await query.first();
} }
public async paginate(request: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> { public async paginate(req: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> {
const page = request.params.page ? parseInt(request.params.page) : 1; const page = req.params.page ? parseInt(req.params.page) : 1;
if (!query) query = this.select(); if (!query) query = this.select();
if (request.params.sortBy) { if (req.params.sortBy) {
const dir = request.params.sortDirection; const dir = req.params.sortDirection;
query = query.sortBy(request.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined); query = query.sortBy(req.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined);
} else { } else {
query = query.sortBy('id'); query = query.sortBy('id');
} }
try {
return await query.paginate(page, perPage); return await query.paginate(page, perPage);
} catch (e) {
if (e instanceof PageNotFoundError) {
throw new NotFoundHttpError(`page ${e.page}`, req.url, e);
} else {
throw e;
}
}
} }
} }

View File

@ -347,7 +347,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
public async paginate(page: number, perPage: number, connection?: Connection): Promise<ModelQueryResult<M>> { public async paginate(page: number, perPage: number, connection?: Connection): Promise<ModelQueryResult<M>> {
this.limit(perPage, (page - 1) * perPage); this.limit(perPage, (page - 1) * perPage);
const result = await this.get(connection); const result = await this.get(connection);
result.pagination = new Pagination<M>(result, page, perPage, await this.count(true, connection)); result.pagination = new Pagination(page, perPage, await this.count(true, connection));
return result; return result;
} }
@ -385,7 +385,7 @@ function inputToFieldOrValue(input: string, addTable?: string): string {
export interface ModelQueryResult<M extends Model> extends Array<M> { export interface ModelQueryResult<M extends Model> extends Array<M> {
originalData?: Record<string, unknown>[]; originalData?: Record<string, unknown>[];
pagination?: Pagination<M>; pagination?: Pagination;
pivot?: Record<string, unknown>[]; pivot?: Record<string, unknown>[];
} }

View File

@ -34,7 +34,7 @@ useApp(async (addr, port) => {
await super.init(); await super.init();
} }
}(addr, port); }(addr, port, true);
}); });
let agent: supertest.SuperTest<supertest.Test>; let agent: supertest.SuperTest<supertest.Test>;
@ -72,7 +72,7 @@ describe('Register with username and password (password)', () => {
.first(); .first();
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(user?.as(UserNameComponent).name).toStrictEqual('entrapta'); expect(user?.as(UserNameComponent).getName()).toStrictEqual('entrapta');
await expect(user?.as(UserPasswordComponent).verifyPassword('darla_is_cute')).resolves.toStrictEqual(true); await expect(user?.as(UserPasswordComponent).verifyPassword('darla_is_cute')).resolves.toStrictEqual(true);
}); });
@ -186,7 +186,7 @@ describe('Register with email (magic_link)', () => {
expect(email).toBeDefined(); expect(email).toBeDefined();
expect(email?.email).toStrictEqual('glimmer@example.org'); expect(email?.email).toStrictEqual('glimmer@example.org');
expect(user?.as(UserNameComponent).name).toStrictEqual('glimmer'); expect(user?.as(UserNameComponent).getName()).toStrictEqual('glimmer');
await expect(user?.as(UserPasswordComponent).verifyPassword('')).resolves.toStrictEqual(false); await expect(user?.as(UserPasswordComponent).verifyPassword('')).resolves.toStrictEqual(false);
}); });
@ -575,7 +575,7 @@ describe('Authenticate with email and password (password)', () => {
expect(email).toBeDefined(); expect(email).toBeDefined();
expect(email?.email).toStrictEqual('double-trouble@example.org'); expect(email?.email).toStrictEqual('double-trouble@example.org');
expect(user?.as(UserNameComponent).name).toStrictEqual('double-trouble'); expect(user?.as(UserNameComponent).getName()).toStrictEqual('double-trouble');
await expect(user?.as(UserPasswordComponent).verifyPassword('trick-or-treat')).resolves.toStrictEqual(true); await expect(user?.as(UserPasswordComponent).verifyPassword('trick-or-treat')).resolves.toStrictEqual(true);
}); });
@ -806,8 +806,154 @@ describe('Change password', () => {
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different')).toBeTruthy(); expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different')).toBeTruthy();
expect(await user?.as(UserPasswordComponent).verifyPassword('123')).toBeFalsy(); expect(await user?.as(UserPasswordComponent).verifyPassword('123')).toBeFalsy();
}); });
test('Remove password', async () => {
let user = await User.select()
.where('name', 'aang')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBe(true);
await agent.post('/account/remove-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Faccount%2F');
await followMagicLinkFromMail(agent, cookies, '/account/');
user = await User.select()
.where('name', 'aang')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBe(false);
}); });
test('Can\'t remove password without contact email', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
csrf = res.text;
await agent.post('/auth/register')
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'password',
identifier: 'eleanor',
password: 'this_is_a_very_strong_password',
password_confirmation: 'this_is_a_very_strong_password',
terms: 'on',
})
.expect(302)
.expect('Location', '/');
let user = await User.select()
.where('name', 'eleanor')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBe(true);
await agent.post('/account/remove-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
})
.expect(302)
.expect('Location', '/account/');
user = await User.select()
.where('name', 'eleanor')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBe(true);
});
});
describe('Change username', () => {
let cookies: string[], csrf: string;
test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
csrf = res.text;
await agent.post('/auth/register')
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'password',
identifier: 'richard',
password: 'a_very_strong_password',
password_confirmation: 'a_very_strong_password',
terms: 'on',
})
.expect(302)
.expect('Location', '/');
});
test('Initial value of name_changed_at should be the same as created_at', async () => {
const user = await User.select().where('name', 'richard').first();
expect(user).toBeDefined();
expect(user?.as(UserNameComponent).getNameChangedAt()?.getTime()).toBe(user?.created_at?.getTime());
});
test('Cannot change username just after registration', async () => {
expect(await User.select().where('name', 'richard').count()).toBe(1);
await agent.post('/account/change-name')
.set('Cookie', cookies)
.send({
csrf: csrf,
name: 'robert',
})
.expect(302)
.expect('Location', '/account/');
expect(await User.select().where('name', 'richard').count()).toBe(1);
});
test('Can change username after hold period', async () => {
// Set last username change to older date
const user = await User.select().where('name', 'richard').first();
if (user) {
user.as(UserNameComponent).forgetNameChangeDate();
await user.save();
}
expect(await User.select().where('name', 'richard').count()).toBe(1);
expect(await User.select().where('name', 'robert').count()).toBe(0);
await agent.post('/account/change-name')
.set('Cookie', cookies)
.send({
csrf: csrf,
name: 'robert',
})
.expect(302)
.expect('Location', '/account/');
expect(await User.select().where('name', 'richard').count()).toBe(0);
expect(await User.select().where('name', 'robert').count()).toBe(1);
});
test('Cannot change username just after changing username', async () => {
expect(await User.select().where('name', 'robert').count()).toBe(1);
expect(await User.select().where('name', 'bebert').count()).toBe(0);
await agent.post('/account/change-name')
.set('Cookie', cookies)
.send({
csrf: csrf,
name: 'bebert',
})
.expect(302)
.expect('Location', '/account/');
expect(await User.select().where('name', 'robert').count()).toBe(1);
expect(await User.select().where('name', 'bebert').count()).toBe(0);
});
});
describe('Manage email addresses', () => { describe('Manage email addresses', () => {

View File

@ -37,7 +37,7 @@ useApp(async (addr, port) => {
protected getMigrations(): MigrationType<Migration>[] { protected getMigrations(): MigrationType<Migration>[] {
return super.getMigrations().filter(m => m !== AddNameToUsersMigration); return super.getMigrations().filter(m => m !== AddNameToUsersMigration);
} }
}(addr, port); }(addr, port, true);
}); });
let agent: supertest.SuperTest<supertest.Test>; let agent: supertest.SuperTest<supertest.Test>;

35
test/Controller.test.ts Normal file
View File

@ -0,0 +1,35 @@
import Controller from "../src/Controller";
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

@ -24,7 +24,7 @@ useApp(async (addr, port) => {
await super.init(); await super.init();
} }
}(addr, port); }(addr, port, true);
}); });
describe('Test CSRF protection', () => { describe('Test CSRF protection', () => {

107
test/Pagination.test.ts Normal file
View File

@ -0,0 +1,107 @@
import Pagination from "../src/Pagination";
describe('Pagination', () => {
const pagination = new Pagination(3, 5, 31);
test('Should have correct page', () => {
expect(pagination.page).toBe(3);
});
test('Should have correct perPage', () => {
expect(pagination.perPage).toBe(5);
});
test('Should have correct totalCount', () => {
expect(pagination.totalCount).toBe(31);
});
test('Should calculate correct last page', () => {
expect(new Pagination(3, 5, 30).lastPage).toBe(6);
expect(new Pagination(3, 5, 31).lastPage).toBe(7);
expect(new Pagination(3, 5, 32).lastPage).toBe(7);
expect(new Pagination(3, 5, 34).lastPage).toBe(7);
expect(new Pagination(3, 5, 35).lastPage).toBe(7);
expect(new Pagination(3, 5, 36).lastPage).toBe(8);
});
test('Should properly tell whether has a previous page', () => {
expect(pagination.hasPrevious()).toBe(true);
expect(new Pagination(1, 5, 15).hasPrevious()).toBe(false);
expect(new Pagination(2, 5, 15).hasPrevious()).toBe(true);
expect(new Pagination(3, 5, 15).hasPrevious()).toBe(true);
expect(new Pagination(1, 5, 17).hasPrevious()).toBe(false);
expect(new Pagination(2, 5, 17).hasPrevious()).toBe(true);
expect(new Pagination(3, 5, 17).hasPrevious()).toBe(true);
expect(new Pagination(4, 5, 17).hasPrevious()).toBe(true);
});
test('Should properly tell whether has a next page', () => {
expect(pagination.hasPrevious()).toBe(true);
expect(new Pagination(1, 5, 15).hasNext()).toBe(true);
expect(new Pagination(2, 5, 15).hasNext()).toBe(true);
expect(new Pagination(3, 5, 15).hasNext()).toBe(false);
expect(new Pagination(1, 5, 17).hasNext()).toBe(true);
expect(new Pagination(2, 5, 17).hasNext()).toBe(true);
expect(new Pagination(3, 5, 17).hasNext()).toBe(true);
expect(new Pagination(4, 5, 17).hasNext()).toBe(false);
});
test('Should throw on out of bound creation attempt', () => {
expect(() => {
new Pagination(-1, 5, 15);
}).toThrow('Page -1 not found.');
expect(() => {
new Pagination(-20, 5, 15);
}).toThrow('Page -20 not found.');
expect(() => {
new Pagination(3, 5, 15);
}).not.toThrow();
expect(() => {
new Pagination(4, 5, 15);
}).toThrow('Page 4 not found.');
expect(() => {
new Pagination(20, 5, 15);
}).toThrow('Page 20 not found.');
});
test('Should generate proper previous pages with context', () => {
expect(new Pagination(1, 1, 100).previousPages(1)).toStrictEqual([]);
expect(new Pagination(1, 1, 100).previousPages(2)).toStrictEqual([]);
expect(new Pagination(1, 1, 100).previousPages(3)).toStrictEqual([]);
expect(new Pagination(2, 1, 100).previousPages(1)).toStrictEqual([1]);
expect(new Pagination(2, 1, 100).previousPages(2)).toStrictEqual([1]);
expect(new Pagination(2, 1, 100).previousPages(3)).toStrictEqual([1]);
expect(new Pagination(3, 1, 100).previousPages(1)).toStrictEqual([1, 2]);
expect(new Pagination(3, 1, 100).previousPages(2)).toStrictEqual([1, 2]);
expect(new Pagination(3, 1, 100).previousPages(3)).toStrictEqual([1, 2]);
expect(new Pagination(10, 1, 100).previousPages(1)).toStrictEqual([1, -1, 9]);
expect(new Pagination(10, 1, 100).previousPages(2)).toStrictEqual([1, 2, -1, 8, 9]);
expect(new Pagination(10, 1, 100).previousPages(3)).toStrictEqual([1, 2, 3, -1, 7, 8, 9]);
});
test('Should generate proper next pages with context', () => {
let pagination = new Pagination(100, 1, 100);
expect(pagination.nextPages(1)).toStrictEqual([]);
expect(pagination.nextPages(2)).toStrictEqual([]);
expect(pagination.nextPages(3)).toStrictEqual([]);
pagination = new Pagination(99, 1, 100);
expect(pagination.nextPages(1)).toStrictEqual([100]);
expect(pagination.nextPages(2)).toStrictEqual([100]);
expect(pagination.nextPages(3)).toStrictEqual([100]);
pagination = new Pagination(98, 1, 100);
expect(pagination.nextPages(1)).toStrictEqual([99, 100]);
expect(pagination.nextPages(2)).toStrictEqual([99, 100]);
expect(pagination.nextPages(3)).toStrictEqual([99, 100]);
pagination = new Pagination(90, 1, 100);
expect(pagination.nextPages(1)).toStrictEqual([91, -1, 100]);
expect(pagination.nextPages(2)).toStrictEqual([91, 92, -1, 99, 100]);
expect(pagination.nextPages(3)).toStrictEqual([91, 92, 93, -1, 98, 99, 100]);
});
});

View File

@ -5,7 +5,7 @@ import MysqlConnectionManager from "../src/db/MysqlConnectionManager";
import config from "config"; import config from "config";
export default function useApp(appSupplier?: (addr: string, port: number) => Promise<TestApp>): void { export default function useApp(appSupplier?: AppSupplier): void {
let app: Application; let app: Application;
beforeAll(async (done) => { beforeAll(async (done) => {
@ -14,7 +14,7 @@ export default function useApp(appSupplier?: (addr: string, port: number) => Pro
await MysqlConnectionManager.endPool(); await MysqlConnectionManager.endPool();
await setupMailServer(); await setupMailServer();
app = appSupplier ? await appSupplier('127.0.0.1', 8966) : new TestApp('127.0.0.1', 8966); app = appSupplier ? await appSupplier('127.0.0.1', 8966) : new TestApp('127.0.0.1', 8966, true);
await app.start(); await app.start();
done(); done();
@ -39,3 +39,5 @@ export default function useApp(appSupplier?: (addr: string, port: number) => Pro
done(); done();
}); });
} }
export type AppSupplier = (addr: string, port: number) => Promise<TestApp>;

View File

@ -22,21 +22,13 @@
{% endif %} {% endif %}
</div> </div>
<section class="panel"> {% if has_name_component %}
<h2><i data-feather="key"></i> {% if has_password %}Change{% else %}Set{% endif %} password</h2> {% include './name_panel.njk' %}
<form action="{{ route('change-password') }}" method="POST">
{% if has_password %}
{{ macros.field(_locals, 'password', 'current_password', null, 'Current password') }}
{% endif %} {% endif %}
{{ macros.field(_locals, 'password', 'new_password', null, 'New password') }}
{{ macros.field(_locals, 'password', 'new_password_confirmation', null, 'New password confirmation') }}
<button type="submit"><i data-feather="save"></i> Save</button> {% if has_password_component %}
{% include './password_panel.njk' %}
{{ macros.csrf(getCsrfToken) }} {% endif %}
</form>
</section>
<section class="panel"> <section class="panel">
<h2 id="emails"><i data-feather="shield"></i> Email addresses</h2> <h2 id="emails"><i data-feather="shield"></i> Email addresses</h2>

View File

@ -0,0 +1,16 @@
<section class="panel">
<h2><i data-feather="key"></i> Change name</h2>
{% if can_change_name %}
<form action="{{ route('change-name') }}" method="POST">
{{ macros.field(_locals, 'text', 'name', null, 'New name', null, 'required') }}
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I understand that I can only change my name once every ' + name_change_wait_period, '', 'required') }}
<button type="submit"><i data-feather="save"></i> Confirm</button>
{{ macros.csrf(getCsrfToken) }}
</form>
{% else %}
{{ macros.message('info', 'You can change your name in ' + can_change_name_in) }}
{% endif %}
</section>

View File

@ -0,0 +1,45 @@
<section class="panel">
<h2><i data-feather="key"></i> {% if has_password %}Change{% else %}Set{% endif %} password</h2>
<form action="{{ route('change-password') }}" method="POST" id="change-password-form">
{% if has_password %}
{{ macros.field(_locals, 'password', 'current_password', null, 'Current password') }}
<p><a href="javascript: void(0);" class="switch-form-link">Forgot your password?</a></p>
{% endif %}
{{ macros.field(_locals, 'password', 'new_password', null, 'New password') }}
{{ macros.field(_locals, 'password', 'new_password_confirmation', null, 'New password confirmation') }}
<button type="submit"><i data-feather="save"></i> Save</button>
{{ macros.csrf(getCsrfToken) }}
</form>
{% if has_password %}
<form action="{{ route('remove-password') }}" method="POST" id="remove-password-form" class="hidden">
<p><a href="javascript: void(0);" class="switch-form-link">Go back</a></p>
<button type="submit" class="danger"><i data-feather="trash"></i> Remove password</button>
{{ macros.csrf(getCsrfToken) }}
</form>
<script>
document.addEventListener('DOMContentLoaded', () => {
const changePasswordForm = document.getElementById('change-password-form');
const removePasswordLink = changePasswordForm.querySelector('a.switch-form-link');
const removePasswordForm = document.getElementById('remove-password-form');
const changePasswordLink = removePasswordForm.querySelector('a.switch-form-link');
removePasswordLink.addEventListener('click', () => {
changePasswordForm.classList.add('hidden');
removePasswordForm.classList.remove('hidden');
});
changePasswordLink.addEventListener('click', () => {
removePasswordForm.classList.add('hidden');
changePasswordForm.classList.remove('hidden');
});
});
</script>
{% endif %}
</section>

View File

@ -141,19 +141,35 @@
</script> </script>
{% endmacro %} {% endmacro %}
{% macro paginate(pagination, routeName) %} {% macro paginate(pagination, routeName, contextSize) %}
{% if pagination.hasPrevious() or pagination.hasNext() %} {% if pagination.hasPrevious() or pagination.hasNext() %}
<div class="pagination"> <nav class="pagination">
<ul>
{% if pagination.hasPrevious() %} {% if pagination.hasPrevious() %}
<a href="{{ route(routeName, {page: pagination.page - 1}) }}"><i data-feather="chevron-left"></i></a> <li><a href="{{ route(routeName, {page: pagination.page - 1}) }}"><i data-feather="chevron-left"></i> Previous</a></li>
{% for i in pagination.previousPages(contextSize) %}
{% if i == -1 %}
<li class="ellipsis">...</li>
{% else %}
<li><a href="{{ route(routeName, {page: i}) }}">{{ i }}</a></li>
{% endif %}
{% endfor %}
{% endif %} {% endif %}
<span>{{ pagination.page }}</span> <li class="active"><span>{{ pagination.page }}</span></li>
{% if pagination.hasNext() %} {% if pagination.hasNext() %}
<a href="{{ route(routeName, {page: pagination.page + 1}) }}"><i data-feather="chevron-right"></i></a> {% for i in pagination.nextPages(contextSize) %}
{% if i == -1 %}
<li class="ellipsis">...</li>
{% else %}
<li><a href="{{ route(routeName, {page: i}) }}">{{ i }}</a></li>
{% endif %} {% endif %}
</div> {% endfor %}
<li><a href="{{ route(routeName, {page: pagination.page + 1}) }}">Next <i data-feather="chevron-right"></i></a></li>
{% endif %}
</ul>
</nav>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}

View File

@ -0,0 +1,27 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
Remove your password on your {{ app.name }} account
</mj-text>
<mj-text>
Someone wants to remove your password from your account.
<br><br>
<strong>Do not click on this if this is not you!</strong>
</mj-text>
<mj-button href="{{ link | safe }}">
Remove my {{ app.name }} password
</mj-button>
</mj-column>
</mj-section>
{% endblock %}
{% block text %}
Hi!
Someone wants to remove your password from your {{ app.name }} account.
To confirm this action and remove your password, please follow this link: {{ link|safe }}
{% endblock %}

1323
yarn.lock

File diff suppressed because it is too large Load Diff