Merge branch 'develop'
This commit is contained in:
commit
55d2040dc3
@ -86,6 +86,7 @@
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"jest.config.js",
|
||||
"scripts/**/*",
|
||||
"dist/**/*",
|
||||
"config/**/*"
|
||||
],
|
||||
|
@ -50,5 +50,8 @@
|
||||
magic_link: {
|
||||
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
|
||||
},
|
||||
}
|
||||
|
16
package.json
16
package.json
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "swaf",
|
||||
"version": "0.23.6",
|
||||
"version": "0.23.7",
|
||||
"description": "Structure Web Application Framework.",
|
||||
"repository": "https://eternae.ink/arisu/swaf",
|
||||
"repository": "https://eternae.ink/ashpie/swaf",
|
||||
"author": "Alice Gaudon <alice@gaudon.pro>",
|
||||
"license": "MIT",
|
||||
"readme": "README.md",
|
||||
@ -14,12 +14,12 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"test": "jest --verbose --runInBand",
|
||||
"clean": "(test ! -d dist || rm -r dist)",
|
||||
"prepareSources": "cp package.json src/",
|
||||
"clean": "node scripts/clean.js",
|
||||
"prepare-sources": "node scripts/prepare-sources.js",
|
||||
"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/",
|
||||
"dev": "yarn prepareSources && 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",
|
||||
"build": "yarn prepare-sources && yarn compile && node scripts/dist.js",
|
||||
"dev": "yarn prepare-sources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"maildev\"",
|
||||
"lint": "eslint .",
|
||||
"release": "yarn build && yarn lint && yarn test && cd dist && yarn publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -46,7 +46,7 @@
|
||||
"@types/ws": "^7.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "^4.2.0",
|
||||
"@typescript-eslint/parser": "^4.2.0",
|
||||
"concurrently": "^5.3.0",
|
||||
"concurrently": "^6.0.0",
|
||||
"eslint": "^7.9.0",
|
||||
"jest": "^26.1.0",
|
||||
"maildev": "^1.1.0",
|
||||
|
10
scripts/clean.js
Normal file
10
scripts/clean.js
Normal 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
34
scripts/dist.js
Normal 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');
|
||||
});
|
4
scripts/prepare-sources.js
Normal file
4
scripts/prepare-sources.js
Normal file
@ -0,0 +1,4 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
fs.copyFileSync('package.json', path.join('src', 'package.json'));
|
@ -21,6 +21,7 @@ import TemplateError = lib.TemplateError;
|
||||
|
||||
export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
|
||||
private readonly version: string;
|
||||
private coreVersion: string = 'unknown';
|
||||
private readonly ignoreCommandLine: boolean;
|
||||
private readonly controllers: Controller[] = [];
|
||||
private readonly webSocketListeners: { [p: string]: WebSocketListener<Application> } = {};
|
||||
@ -28,6 +29,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
||||
private cacheProvider?: CacheProvider;
|
||||
|
||||
private ready: boolean = false;
|
||||
private started: boolean = false;
|
||||
private busy: boolean = false;
|
||||
|
||||
protected constructor(version: string, ignoreCommandLine: boolean = false) {
|
||||
this.version = version;
|
||||
@ -58,23 +61,46 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
logger.info(`${config.get('app.name')} v${this.version} - hi`);
|
||||
process.once('SIGINT', () => {
|
||||
if (this.started) throw new Error('Application already started');
|
||||
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);
|
||||
});
|
||||
};
|
||||
process.once('exit', exitHandler);
|
||||
process.once('SIGINT', exitHandler);
|
||||
process.once('SIGUSR1', exitHandler);
|
||||
process.once('SIGUSR2', exitHandler);
|
||||
process.once('uncaughtException', exitHandler);
|
||||
|
||||
// Register migrations
|
||||
MysqlConnectionManager.registerMigrations(this.getMigrations());
|
||||
|
||||
// Process command line
|
||||
if (!this.ignoreCommandLine && await this.processCommandLine()) {
|
||||
await this.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Register all components and alike
|
||||
await this.init();
|
||||
|
||||
// Process command line
|
||||
if (!this.ignoreCommandLine && await this.processCommandLine()) {
|
||||
this.started = true;
|
||||
this.busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Security
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
await this.checkSecuritySettings();
|
||||
@ -189,26 +215,54 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
||||
this.routes(initRouter, handleRouter);
|
||||
|
||||
this.ready = true;
|
||||
this.started = true;
|
||||
this.busy = false;
|
||||
}
|
||||
|
||||
protected async processCommandLine(): Promise<boolean> {
|
||||
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++) {
|
||||
switch (args[i]) {
|
||||
case '--verbose':
|
||||
logger.setSettings({minLevel: "trace"});
|
||||
flags.verbose = true;
|
||||
break;
|
||||
case '--full-http-requests':
|
||||
LogRequestsComponent.logFullHttpRequests();
|
||||
flags.fullHttpRequests = true;
|
||||
break;
|
||||
case 'migration':
|
||||
await MysqlConnectionManager.migrationCommand(args.slice(i + 1));
|
||||
return true;
|
||||
if (mainCommand === null) mainCommand = args[i];
|
||||
else throw new Error(`Only one main command can be used at once (${mainCommand},${args[i]})`);
|
||||
break;
|
||||
default:
|
||||
logger.warn('Unrecognized argument', args[i]);
|
||||
if (mainCommand) mainCommandArgs.push(args[i]);
|
||||
else logger.fatal('Unrecognized argument', args[i]);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -233,13 +287,18 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (this.started && !this.busy) {
|
||||
this.busy = true;
|
||||
logger.info('Stopping application...');
|
||||
|
||||
for (const component of this.components) {
|
||||
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) {
|
||||
@ -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> } {
|
||||
return this.webSocketListeners;
|
||||
}
|
||||
@ -292,4 +343,20 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
||||
Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,9 @@ import Middleware, {MiddlewareType} from "./Middleware";
|
||||
import Application from "./Application";
|
||||
|
||||
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(
|
||||
@ -20,11 +23,12 @@ export default abstract class Controller {
|
||||
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(/:[a-zA-Z_-]+\??/g, '' + params);
|
||||
path = path.replace(regExp, '' + params);
|
||||
} else if (Array.isArray(params)) {
|
||||
let i = 0;
|
||||
for (const match of path.matchAll(/:[a-zA-Z_-]+(\(.*\))?\??/g)) {
|
||||
for (const match of path.matchAll(regExp)) {
|
||||
if (match.length > 0) {
|
||||
path = path.replace(match[0], typeof params[i] !== 'undefined' ? params[i] : '');
|
||||
}
|
||||
@ -33,7 +37,7 @@ export default abstract class Controller {
|
||||
path = path.replace(/\/+/g, '/');
|
||||
} else {
|
||||
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 : '');
|
||||
}
|
||||
|
||||
private static getRouteParamRegExp(key: string, flags?: string): RegExp {
|
||||
return new RegExp(`:${key}(\\(.+?\\))?\\??`, flags);
|
||||
}
|
||||
|
||||
private readonly router: Router = express.Router();
|
||||
private readonly fileUploadFormRouter: Router = express.Router();
|
||||
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;
|
||||
|
@ -1,10 +1,10 @@
|
||||
import {IncomingForm} from "formidable";
|
||||
import Formidable from "formidable";
|
||||
import Middleware from "./Middleware";
|
||||
import {NextFunction, Request, Response} from "express";
|
||||
import {FileError, ValidationBag} from "./db/Validator";
|
||||
|
||||
export default abstract class FileUploadMiddleware extends Middleware {
|
||||
protected abstract makeForm(): IncomingForm;
|
||||
protected abstract makeForm(): Formidable;
|
||||
|
||||
protected abstract getDefaultField(): string;
|
||||
|
||||
|
@ -33,7 +33,7 @@ export const preventContextCorruptionMiddleware = (delegate: RequestHandler): Re
|
||||
) => {
|
||||
const data = requestIdStorage.getStore() as string;
|
||||
|
||||
delegate(req, res, (err?: Error | 'router') => {
|
||||
delegate(req, res, (err?: unknown | 'router') => {
|
||||
requestIdStorage.enterWith(data);
|
||||
next(err);
|
||||
});
|
||||
|
@ -22,3 +22,8 @@ export const ADD_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
|
||||
'add_email',
|
||||
(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.`,
|
||||
);
|
||||
|
@ -1,16 +1,18 @@
|
||||
import Model from "./db/Model";
|
||||
import {WrappingError} from "./Utils";
|
||||
|
||||
export default class Pagination<T extends Model> {
|
||||
private readonly models: T[];
|
||||
export default class Pagination {
|
||||
public readonly page: number;
|
||||
public readonly perPage: number;
|
||||
public readonly totalCount: number;
|
||||
|
||||
public constructor(models: T[], page: number, perPage: number, totalCount: number) {
|
||||
this.models = models;
|
||||
public constructor(page: number, perPage: number, totalCount: number) {
|
||||
this.page = page;
|
||||
this.perPage = perPage;
|
||||
this.totalCount = totalCount;
|
||||
|
||||
if (this.page < 1 || this.page > this.lastPage) {
|
||||
throw new PageNotFoundError(this.page);
|
||||
}
|
||||
}
|
||||
|
||||
public hasPrevious(): boolean {
|
||||
@ -18,7 +20,69 @@ export default class Pagination<T extends Model> {
|
||||
}
|
||||
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagic
|
||||
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration";
|
||||
import packageJson = require('./package.json');
|
||||
import PreviousUrlComponent from "./components/PreviousUrlComponent";
|
||||
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration";
|
||||
|
||||
export const MIGRATIONS = [
|
||||
CreateMigrationsTable,
|
||||
@ -40,14 +41,15 @@ export const MIGRATIONS = [
|
||||
AddNameToUsersMigration,
|
||||
MakeMagicLinksSessionNotUniqueMigration,
|
||||
AddUsedToMagicLinksMigration,
|
||||
AddNameChangedAtToUsersMigration,
|
||||
];
|
||||
|
||||
export default class TestApp extends Application {
|
||||
private readonly addr: string;
|
||||
private readonly port: number;
|
||||
|
||||
public constructor(addr: string, port: number) {
|
||||
super(packageJson.version, true);
|
||||
public constructor(addr: string, port: number, ignoreCommandLine: boolean = false) {
|
||||
super(packageJson.version, ignoreCommandLine);
|
||||
this.addr = addr;
|
||||
this.port = port;
|
||||
}
|
||||
|
93
src/Time.ts
Normal file
93
src/Time.ts
Normal 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';
|
||||
}
|
||||
|
||||
}
|
@ -10,15 +10,18 @@ import ModelFactory from "../db/ModelFactory";
|
||||
import UserEmail from "./models/UserEmail";
|
||||
import MagicLinkController from "./magic_link/MagicLinkController";
|
||||
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 UserNameComponent from "./models/UserNameComponent";
|
||||
import Time from "../Time";
|
||||
|
||||
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();
|
||||
this.addEmailMailTemplate = addEmailMailTemplate;
|
||||
}
|
||||
|
||||
public getRoutesPrefix(): string {
|
||||
@ -28,8 +31,13 @@ export default class AccountController extends Controller {
|
||||
public routes(): void {
|
||||
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)) {
|
||||
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);
|
||||
@ -40,14 +48,48 @@ export default class AccountController extends Controller {
|
||||
protected async getAccount(req: Request, res: Response): Promise<void> {
|
||||
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(),
|
||||
emails: await user.emails.get(),
|
||||
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> {
|
||||
const validationMap = {
|
||||
'new_password': new Validator().defined(),
|
||||
@ -69,6 +111,31 @@ export default class AccountController extends Controller {
|
||||
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> {
|
||||
await Validator.validate({
|
||||
|
@ -141,7 +141,7 @@ export default class AuthGuard {
|
||||
|
||||
if (!user.isApproved()) {
|
||||
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') ||
|
||||
'Could not find an identifier',
|
||||
link: config.get<string>('public_url') + Controller.route('accounts-approval'),
|
||||
|
@ -2,4 +2,5 @@ export default {
|
||||
LOGIN: 'login',
|
||||
REGISTER: 'register',
|
||||
ADD_EMAIL: 'add_email',
|
||||
REMOVE_PASSWORD: 'remove_password',
|
||||
};
|
||||
|
@ -18,6 +18,7 @@ import {QueryVariable} from "../../db/MysqlConnectionManager";
|
||||
import UserNameComponent from "../models/UserNameComponent";
|
||||
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
|
||||
import {logger} from "../../Logger";
|
||||
import UserPasswordComponent from "../password/UserPasswordComponent";
|
||||
|
||||
export default class MagicLinkController<A extends Application> extends Controller {
|
||||
public static async sendMagicLink(
|
||||
@ -64,7 +65,12 @@ export default class MagicLinkController<A extends Application> extends Controll
|
||||
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
|
||||
session, magicLink, !!session.wantsSessionPersistence, undefined, async (connection, user) => {
|
||||
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 [];
|
||||
}, async (connection, user) => {
|
||||
const callbacks: RegisterCallback[] = [];
|
||||
@ -191,7 +197,7 @@ export default class MagicLinkController<A extends Application> extends Controll
|
||||
|
||||
if (!res.headersSent && user) {
|
||||
// Auth success
|
||||
const name = user.asOptional(UserNameComponent)?.name;
|
||||
const name = user.asOptional(UserNameComponent)?.getName();
|
||||
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
|
||||
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'));
|
||||
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:
|
||||
logger.warn('Unknown magic link action type ' + magicLink.action_type);
|
||||
break;
|
||||
|
12
src/auth/migrations/AddNameChangedAtToUsersMigration.ts
Normal file
12
src/auth/migrations/AddNameChangedAtToUsersMigration.ts
Normal 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');
|
||||
}
|
||||
}
|
@ -9,7 +9,7 @@ import UserNameComponent from "./UserNameComponent";
|
||||
|
||||
export default class User extends Model {
|
||||
public static isApprovalMode(): boolean {
|
||||
return config.get<boolean>('approval_mode') &&
|
||||
return config.get<boolean>('auth.approval_mode') &&
|
||||
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration);
|
||||
}
|
||||
|
||||
@ -42,10 +42,10 @@ export default class User extends Model {
|
||||
protected getPersonalInfoFields(): { name: string, value: string }[] {
|
||||
const fields: { name: string, value: string }[] = [];
|
||||
const nameComponent = this.asOptional(UserNameComponent);
|
||||
if (nameComponent && nameComponent.name) {
|
||||
if (nameComponent && nameComponent.hasName()) {
|
||||
fields.push({
|
||||
name: 'Name',
|
||||
value: nameComponent.name,
|
||||
value: nameComponent.getName(),
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
|
@ -1,12 +1,44 @@
|
||||
import ModelComponent from "../../db/ModelComponent";
|
||||
import User from "../models/User";
|
||||
import config from "config";
|
||||
|
||||
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
|
||||
|
||||
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 {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
@ -113,7 +113,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
||||
await user.as(UserPasswordComponent).setPassword(req.body.password);
|
||||
|
||||
// Username
|
||||
user.as(UserNameComponent).name = req.body.identifier;
|
||||
user.as(UserNameComponent).setName(req.body.identifier);
|
||||
|
||||
return callbacks;
|
||||
}, async (connection, user) => {
|
||||
@ -132,7 +132,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ import Validator from "../../db/Validator";
|
||||
export default class UserPasswordComponent extends ModelComponent<User> {
|
||||
public static readonly PASSWORD_MIN_LENGTH = 12;
|
||||
|
||||
private password?: string = undefined;
|
||||
private password?: string | null = undefined;
|
||||
|
||||
public init(): void {
|
||||
this.setValidation('password').acceptUndefined().maxLength(128);
|
||||
@ -36,4 +36,8 @@ export default class UserPasswordComponent extends ModelComponent<User> {
|
||||
public hasPassword(): boolean {
|
||||
return typeof this.password === 'string';
|
||||
}
|
||||
|
||||
public removePassword(): void {
|
||||
this.password = null;
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,6 @@ import * as querystring from "querystring";
|
||||
import {ParsedUrlQueryInput} from "querystring";
|
||||
import * as util from "util";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import {logger} from "../Logger";
|
||||
import Middleware from "../Middleware";
|
||||
|
||||
export default class NunjucksComponent extends ApplicationComponent {
|
||||
@ -21,17 +19,6 @@ export default class NunjucksComponent extends ApplicationComponent {
|
||||
}
|
||||
|
||||
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 = {
|
||||
autoescape: true,
|
||||
noCache: !config.get('view.cache'),
|
||||
@ -51,7 +38,7 @@ export default class NunjucksComponent extends ApplicationComponent {
|
||||
return Controller.route(route, params, query, absolute);
|
||||
})
|
||||
.addGlobal('app_version', this.getApp().getVersion())
|
||||
.addGlobal('core_version', coreVersion)
|
||||
.addGlobal('core_version', this.getApp().getCoreVersion())
|
||||
.addGlobal('querystring', querystring)
|
||||
.addGlobal('app', config.get('app'))
|
||||
|
||||
|
@ -2,6 +2,7 @@ import ApplicationComponent from "../ApplicationComponent";
|
||||
import express, {Router} from "express";
|
||||
import {PathParams} from "express-serve-static-core";
|
||||
import * as path from "path";
|
||||
import {logger} from "../Logger";
|
||||
|
||||
export default class ServeStaticDirectoryComponent extends ApplicationComponent {
|
||||
private readonly root: string;
|
||||
@ -9,16 +10,20 @@ export default class ServeStaticDirectoryComponent extends ApplicationComponent
|
||||
|
||||
public constructor(root: string, routePath?: PathParams) {
|
||||
super();
|
||||
this.root = path.join(__dirname, '../../../', root);
|
||||
this.root = root;
|
||||
this.path = routePath;
|
||||
}
|
||||
|
||||
public async init(router: Router): Promise<void> {
|
||||
const resolvedRoot = path.join(__dirname, this.getApp().isInNodeModules() ? '../../../' : '../../', this.root);
|
||||
|
||||
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 {
|
||||
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 || '/');
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ import ModelComponent from "./ModelComponent";
|
||||
import Model, {ModelType} from "./Model";
|
||||
import ModelQuery, {ModelQueryResult, QueryFields} from "./ModelQuery";
|
||||
import {Request} from "express";
|
||||
import {PageNotFoundError} from "../Pagination";
|
||||
import {NotFoundHttpError} from "../HttpError";
|
||||
|
||||
export default class ModelFactory<M extends Model> {
|
||||
private static readonly factories: { [modelType: string]: ModelFactory<Model> | undefined } = {};
|
||||
@ -86,16 +88,25 @@ export default class ModelFactory<M extends Model> {
|
||||
return await query.first();
|
||||
}
|
||||
|
||||
public async paginate(request: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> {
|
||||
const page = request.params.page ? parseInt(request.params.page) : 1;
|
||||
public async paginate(req: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> {
|
||||
const page = req.params.page ? parseInt(req.params.page) : 1;
|
||||
if (!query) query = this.select();
|
||||
if (request.params.sortBy) {
|
||||
const dir = request.params.sortDirection;
|
||||
query = query.sortBy(request.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined);
|
||||
if (req.params.sortBy) {
|
||||
const dir = req.params.sortDirection;
|
||||
query = query.sortBy(req.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined);
|
||||
} else {
|
||||
query = query.sortBy('id');
|
||||
}
|
||||
|
||||
try {
|
||||
return await query.paginate(page, perPage);
|
||||
} catch (e) {
|
||||
if (e instanceof PageNotFoundError) {
|
||||
throw new NotFoundHttpError(`page ${e.page}`, req.url, e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>> {
|
||||
this.limit(perPage, (page - 1) * perPage);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -385,7 +385,7 @@ function inputToFieldOrValue(input: string, addTable?: string): string {
|
||||
|
||||
export interface ModelQueryResult<M extends Model> extends Array<M> {
|
||||
originalData?: Record<string, unknown>[];
|
||||
pagination?: Pagination<M>;
|
||||
pagination?: Pagination;
|
||||
pivot?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ useApp(async (addr, port) => {
|
||||
|
||||
await super.init();
|
||||
}
|
||||
}(addr, port);
|
||||
}(addr, port, true);
|
||||
});
|
||||
|
||||
let agent: supertest.SuperTest<supertest.Test>;
|
||||
@ -72,7 +72,7 @@ describe('Register with username and password (password)', () => {
|
||||
.first();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@ -186,7 +186,7 @@ describe('Register with email (magic_link)', () => {
|
||||
expect(email).toBeDefined();
|
||||
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);
|
||||
});
|
||||
|
||||
@ -575,7 +575,7 @@ describe('Authenticate with email and password (password)', () => {
|
||||
expect(email).toBeDefined();
|
||||
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);
|
||||
});
|
||||
|
||||
@ -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('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', () => {
|
||||
|
||||
|
@ -37,7 +37,7 @@ useApp(async (addr, port) => {
|
||||
protected getMigrations(): MigrationType<Migration>[] {
|
||||
return super.getMigrations().filter(m => m !== AddNameToUsersMigration);
|
||||
}
|
||||
}(addr, port);
|
||||
}(addr, port, true);
|
||||
});
|
||||
|
||||
let agent: supertest.SuperTest<supertest.Test>;
|
||||
|
35
test/Controller.test.ts
Normal file
35
test/Controller.test.ts
Normal 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');
|
||||
});
|
||||
});
|
@ -24,7 +24,7 @@ useApp(async (addr, port) => {
|
||||
|
||||
await super.init();
|
||||
}
|
||||
}(addr, port);
|
||||
}(addr, port, true);
|
||||
});
|
||||
|
||||
describe('Test CSRF protection', () => {
|
||||
|
107
test/Pagination.test.ts
Normal file
107
test/Pagination.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
@ -5,7 +5,7 @@ import MysqlConnectionManager from "../src/db/MysqlConnectionManager";
|
||||
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;
|
||||
|
||||
beforeAll(async (done) => {
|
||||
@ -14,7 +14,7 @@ export default function useApp(appSupplier?: (addr: string, port: number) => Pro
|
||||
await MysqlConnectionManager.endPool();
|
||||
|
||||
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();
|
||||
done();
|
||||
@ -39,3 +39,5 @@ export default function useApp(appSupplier?: (addr: string, port: number) => Pro
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
export type AppSupplier = (addr: string, port: number) => Promise<TestApp>;
|
||||
|
@ -22,21 +22,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{% if has_password %}
|
||||
{{ macros.field(_locals, 'password', 'current_password', null, 'Current password') }}
|
||||
{% if has_name_component %}
|
||||
{% include './name_panel.njk' %}
|
||||
{% 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>
|
||||
</section>
|
||||
{% if has_password_component %}
|
||||
{% include './password_panel.njk' %}
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2 id="emails"><i data-feather="shield"></i> Email addresses</h2>
|
16
views/auth/account/name_panel.njk
Normal file
16
views/auth/account/name_panel.njk
Normal 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>
|
45
views/auth/account/password_panel.njk
Normal file
45
views/auth/account/password_panel.njk
Normal 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>
|
@ -141,19 +141,35 @@
|
||||
</script>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate(pagination, routeName) %}
|
||||
{% macro paginate(pagination, routeName, contextSize) %}
|
||||
{% if pagination.hasPrevious() or pagination.hasNext() %}
|
||||
<div class="pagination">
|
||||
<nav class="pagination">
|
||||
<ul>
|
||||
{% 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 %}
|
||||
|
||||
<span>{{ pagination.page }}</span>
|
||||
<li class="active"><span>{{ pagination.page }}</span></li>
|
||||
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<li><a href="{{ route(routeName, {page: pagination.page + 1}) }}">Next <i data-feather="chevron-right"></i></a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
27
views/mails/remove_password.mjml.njk
Normal file
27
views/mails/remove_password.mjml.njk
Normal 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 %}
|
Loading…
Reference in New Issue
Block a user