Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2020-08-11 19:03:19 +02:00
commit 09e9b968fe
20 changed files with 828 additions and 898 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "ily.li", "name": "ily.li",
"version": "0.4.6", "version": "0.5.0",
"description": "Self-hosted file pusher", "description": "Self-hosted file pusher",
"repository": "git@gitlab.com:ArisuOngaku/ily.li.git", "repository": "git@gitlab.com:ArisuOngaku/ily.li.git",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",
@ -22,13 +22,13 @@
"@types/formidable": "^1.0.31", "@types/formidable": "^1.0.31",
"@types/jest": "^26.0.4", "@types/jest": "^26.0.4",
"@types/mysql": "^2.15.15", "@types/mysql": "^2.15.15",
"@types/node": "^13.13.2", "@types/node": "^14.0.27",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3", "@types/nunjucks": "^3.1.3",
"@types/ws": "^7.2.6", "@types/ws": "^7.2.6",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"concurrently": "^5.1.0", "concurrently": "^5.1.0",
"css-loader": "^3.5.2", "css-loader": "^4.2.1",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"imagemin": "^7.0.1", "imagemin": "^7.0.1",
@ -38,7 +38,7 @@
"imagemin-svgo": "^8.0.0", "imagemin-svgo": "^8.0.0",
"img-loader": "^3.0.1", "img-loader": "^3.0.1",
"jest": "^26.1.0", "jest": "^26.1.0",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.10.0",
"node-sass": "^4.14.0", "node-sass": "^4.14.0",
"nodemon": "^2.0.3", "nodemon": "^2.0.3",
"sass-loader": "^9.0.2", "sass-loader": "^9.0.2",

View File

@ -34,11 +34,12 @@ import {Request} from "express";
import CreateFilesTable from "./migrations/CreateFilesTable"; import CreateFilesTable from "./migrations/CreateFilesTable";
import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField"; import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField";
import AddApprovedFieldToUsersTable from "wms-core/auth/migrations/AddApprovedFieldToUsersTable"; import AddApprovedFieldToUsersTable from "wms-core/auth/migrations/AddApprovedFieldToUsersTable";
import BackendController from "./controllers/BackendController";
import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable"; import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable";
import AuthTokenController from "./controllers/AuthTokenController"; import AuthTokenController from "./controllers/AuthTokenController";
import URLRedirectController from "./controllers/URLRedirectController"; import URLRedirectController from "./controllers/URLRedirectController";
import LinkController from "./controllers/LinkController"; import LinkController from "./controllers/LinkController";
import BackendController from "wms-core/helpers/BackendController";
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
export default class App extends Application { export default class App extends Application {
private readonly port: number; private readonly port: number;
@ -75,13 +76,18 @@ export default class App extends Application {
const expressAppComponent = new ExpressAppComponent(this.port); const expressAppComponent = new ExpressAppComponent(this.port);
this.use(expressAppComponent); this.use(expressAppComponent);
this.use(new NunjucksComponent());
// Base
this.use(new LogRequestsComponent()); this.use(new LogRequestsComponent());
// Static files // Static files
this.use(new ServeStaticDirectoryComponent('public')); this.use(new ServeStaticDirectoryComponent('public'));
this.use(new ServeStaticDirectoryComponent('node_modules/feather-icons/dist', '/icons')); this.use(new ServeStaticDirectoryComponent('node_modules/feather-icons/dist', '/icons'));
// Dynamic views and routes
this.use(new NunjucksComponent());
this.use(new RedirectBackComponent());
// Maintenance // Maintenance
this.use(new MaintenanceComponent(this, () => { this.use(new MaintenanceComponent(this, () => {
return redisComponent.canServe() && mysqlComponent.canServe(); return redisComponent.canServe() && mysqlComponent.canServe();
@ -95,6 +101,8 @@ export default class App extends Application {
// Session // Session
this.use(redisComponent); this.use(redisComponent);
this.use(new SessionComponent(redisComponent)); this.use(new SessionComponent(redisComponent));
// Auth
this.use(new AuthComponent(new class extends AuthGuard<MagicLink | AuthToken> { this.use(new AuthComponent(new class extends AuthGuard<MagicLink | AuthToken> {
public async getProofForSession(session: Express.Session): Promise<any | null> { public async getProofForSession(session: Express.Session): Promise<any | null> {
return await MagicLink.bySessionID(session.id, [MagicLinkActionType.LOGIN, MagicLinkActionType.REGISTER]); return await MagicLink.bySessionID(session.id, [MagicLinkActionType.LOGIN, MagicLinkActionType.REGISTER]);

View File

@ -1,6 +0,0 @@
import {MailTemplate} from "wms-core/Mail";
export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link',
data => data.type === 'register' ? 'Registration' : 'Login magic link'
);

View File

@ -1,12 +0,0 @@
import {MailTemplate} from "wms-core/Mail";
import config from "config";
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'account_review_notice',
data => `Your account was ${data.approved ? 'approved' : 'rejected'}.`
);
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'pending_account_review',
() => 'A new account is pending review on ' + config.get<string>('domain'),
);

10
src/Utils.ts Normal file
View File

@ -0,0 +1,10 @@
export function encodeRFC5987ValueChars(str: string) {
return encodeURIComponent(str).
// Note that although RFC3986 reserves "!", RFC5987 does not,
// so we do not need to escape it
replace(/['()*]/g, c => "%" + c.charCodeAt(0).toString(16)). // i.e., %27 %28 %29 %2a (Note that valid encoding of "*" is %2A
// which necessitates calling toUpperCase() to properly encode)
// The following are not required for percent-encoding per RFC5987,
// so we can allow for a little better readability over the wire: |`^
replace(/%(7C|60|5E)/g, (str, hex) => String.fromCharCode(parseInt(hex, 16)));
}

View File

@ -1,5 +1,5 @@
import MagicLinkAuthController from "wms-core/auth/magic_link/MagicLinkAuthController"; import MagicLinkAuthController from "wms-core/auth/magic_link/MagicLinkAuthController";
import {MAGIC_LINK_MAIL} from "../MagicLinkMail"; import {MAGIC_LINK_MAIL} from "wms-core/Mails";
export default class AuthController extends MagicLinkAuthController { export default class AuthController extends MagicLinkAuthController {
public constructor() { public constructor() {

View File

@ -1,69 +0,0 @@
import Controller from "wms-core/Controller";
import {REQUIRE_ADMIN_MIDDLEWARE, REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import User from "wms-core/auth/models/User";
import {NotFoundHttpError} from "wms-core/HttpError";
import {ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE} from "../Mails";
import Mail from "wms-core/Mail";
import config from "config";
export default class BackendController extends Controller {
getRoutesPrefix(): string {
return '/backend';
}
routes(): void {
this.get('/', this.getIndex, 'backend', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
if (User.isApprovalMode()) {
this.get('/accounts-approval', this.getAccountApproval, 'accounts-approval', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.get('/accounts-approval/approve/:id', this.getApproveAccount, 'approve-account', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.get('/accounts-approval/reject/:id', this.getRejectAccount, 'reject-account', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
}
}
public async getIndex(req: Request, res: Response): Promise<void> {
res.render('backend/index', {
approval_mode: User.isApprovalMode(),
accounts_to_approve: User.isApprovalMode() ? await User.select().count() : 0,
});
}
public async getAccountApproval(req: Request, res: Response): Promise<void> {
const accounts = await User.select().where('approved', 0).with('mainEmail').get();
res.render('backend/accounts_approval', {
accounts: User.isApprovalMode() ? accounts : 0,
});
}
public async getApproveAccount(req: Request, res: Response): Promise<void> {
const account = await User.select().where('id', req.params.id).with('mainEmail').first();
if (!account) throw new NotFoundHttpError('User', req.url);
const email = await account.mainEmail.get();
account.approved = true;
await account.save();
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: true,
link: config.get<string>('base_url') + Controller.route('auth'),
}).send(email!.email!);
req.flash('success', `Account successfully approved.`);
res.redirectBack(Controller.route('accounts-approval'));
}
public async getRejectAccount(req: Request, res: Response): Promise<void> {
const account = await User.select().where('id', req.params.id).with('mainEmail').first();
if (!account) throw new NotFoundHttpError('User', req.url);
const email = await account.mainEmail.get();
await account.delete();
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: false,
}).send(email!.email!);
req.flash('success', `Account successfully deleted.`);
res.redirectBack(Controller.route('accounts-approval'));
}
}

View File

@ -60,7 +60,7 @@ export default class FileController extends Controller {
if (req.body.ttl !== undefined) ttl = parseInt(req.body.ttl); if (req.body.ttl !== undefined) ttl = parseInt(req.body.ttl);
else if (req.body.expire_after_days !== undefined) ttl = parseInt(req.body.expire_after_days) * 24 * 3600; else if (req.body.expire_after_days !== undefined) ttl = parseInt(req.body.expire_after_days) * 24 * 3600;
const file = new FileModel({ const file = FileModel.create({
user_id: req.models.user!.id, user_id: req.models.user!.id,
slug: slug, slug: slug,
real_name: upload.name, real_name: upload.name,
@ -71,7 +71,7 @@ export default class FileController extends Controller {
}); });
await file.save(); await file.save();
fs.renameSync(upload.path, file.storage_path); fs.renameSync(upload.path, file.storage_path!);
const domain = req.body.url_domain || config.get<string[]>('allowed_url_domains')[config.get<number>('default_url_domain_for_files')]; const domain = req.body.url_domain || config.get<string[]>('allowed_url_domains')[config.get<number>('default_url_domain_for_files')];
res.format({ res.format({
@ -116,7 +116,7 @@ export default class FileController extends Controller {
} }
public static async deleteFile(file: FileModel): Promise<void> { public static async deleteFile(file: FileModel): Promise<void> {
fs.unlinkSync(file.storage_path); fs.unlinkSync(file.storage_path!);
await file.delete(); await file.delete();
Logger.info('Deleted', file.storage_path, `(${file.real_name})`); Logger.info('Deleted', file.storage_path, `(${file.real_name})`);
} }

View File

@ -1,6 +1,6 @@
import Controller from "wms-core/Controller"; import Controller from "wms-core/Controller";
import {NextFunction, Request, Response} from "express"; import {NextFunction, Request, Response} from "express";
import {BadRequestError, NotFoundHttpError, ServerError} from "wms-core/HttpError"; import {BadRequestError, ForbiddenHttpError, NotFoundHttpError, ServerError} from "wms-core/HttpError";
import config from "config"; import config from "config";
import {REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent"; import {REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import URLRedirect from "../models/URLRedirect"; import URLRedirect from "../models/URLRedirect";
@ -8,12 +8,14 @@ import URLRedirectController from "./URLRedirectController";
import FileModel from "../models/FileModel"; import FileModel from "../models/FileModel";
import generateSlug from "../SlugGenerator"; import generateSlug from "../SlugGenerator";
import FileController, {FILE_UPLOAD_FORM_MIDDLEWARE} from "./FileController"; import FileController, {FILE_UPLOAD_FORM_MIDDLEWARE} from "./FileController";
import * as fs from "fs";
import {encodeRFC5987ValueChars} from "../Utils";
export default class LinkController extends Controller { export default class LinkController extends Controller {
routes(): void { routes(): void {
this.post('/', this.postFile, 'post-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE); this.post('/', this.postFile, 'post-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
this.delete('/:slug', FileController.deleteFileRoute, 'delete-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE); this.delete('/:slug', FileController.deleteFileRoute, 'delete-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
this.get('/:slug', this.downloadFile, 'get-file'); this.get('/:slug', this.getFile, 'get-file');
this.put('/:slug', this.putFile, 'put-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE); this.put('/:slug', this.putFile, 'put-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
this.post('/', URLRedirectController.addURL, 'post-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE); this.post('/', URLRedirectController.addURL, 'post-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
@ -24,7 +26,7 @@ export default class LinkController extends Controller {
this.get(/(.*)/, this.domainFilter); this.get(/(.*)/, this.domainFilter);
} }
protected async downloadFile(req: Request, res: Response, next: NextFunction): Promise<void> { protected async getFile(req: Request, res: Response, next: NextFunction): Promise<void> {
console.log('get file', req.params.slug) console.log('get file', req.params.slug)
const file = await FileModel.getBySlug(req.params.slug); const file = await FileModel.getBySlug(req.params.slug);
if (!file) return next(); if (!file) return next();
@ -33,9 +35,20 @@ export default class LinkController extends Controller {
return next(); return next();
} }
// File type
const fileName = file.real_name!;
const parts = fileName.split('.');
res.type(parts[parts.length - 1]);
// File name
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeRFC5987ValueChars(fileName)}`);
switch (file.storage_type) { switch (file.storage_type) {
case 'local': case 'local':
res.download(file.storage_path, file.real_name); fs.readFile(file.storage_path!, (err, data) => {
if (err) return next(err);
res.send(data);
});
break; break;
default: default:
throw new ServerError(`This file cannot be served. Download protocol for ${file.storage_type} storage type not implemented.`); throw new ServerError(`This file cannot be served. Download protocol for ${file.storage_type} storage type not implemented.`);
@ -60,7 +73,7 @@ export default class LinkController extends Controller {
const url = await URLRedirect.getBySlug(req.params.slug); const url = await URLRedirect.getBySlug(req.params.slug);
if (!url) return next(); if (!url) return next();
res.redirect(url.target_url, 301); res.redirect(url.target_url!, 301);
} }
protected async deleteURL(req: Request, res: Response, next: NextFunction): Promise<void> { protected async deleteURL(req: Request, res: Response, next: NextFunction): Promise<void> {

View File

@ -16,11 +16,12 @@ export default class MagicLinkController extends _MagicLinkController {
case MagicLinkActionType.LOGIN: case MagicLinkActionType.LOGIN:
case MagicLinkActionType.REGISTER: case MagicLinkActionType.REGISTER:
await AuthController.checkAndAuth(req, res, magicLink); await AuthController.checkAndAuth(req, res, magicLink);
const proof = await req.authGuard.isAuthenticated(req.session!);
const user = await proof?.getResource();
if (!res.headersSent) { if (!res.headersSent && user) {
// Auth success // Auth success
const user = await req.authGuard.getUserForSession(req.session!); req.flash('success', `Authentication success. Welcome, ${user.name}!`);
req.flash('success', `Authentication success. Welcome, ${user?.name}!`);
res.redirect(req.query.redirect_uri?.toString() || Controller.route('home')); res.redirect(req.query.redirect_uri?.toString() || Controller.route('home'));
} }
break; break;

View File

@ -1,21 +1,28 @@
import {Connection} from "mysql"; import {Connection} from "mysql";
import Migration from "wms-core/db/Migration"; import Migration from "wms-core/db/Migration";
import ModelFactory from "wms-core/db/ModelFactory";
import AuthToken from "../models/AuthToken";
export default class CreateAuthTokensTable extends Migration { export default class CreateAuthTokensTable extends Migration {
public async install(connection: Connection): Promise<void> { public async install(connection: Connection): Promise<void> {
await this.query('CREATE TABLE auth_tokens(' + await this.query(`CREATE TABLE auth_tokens
'id INT NOT NULL AUTO_INCREMENT,' + (
'user_id INT NOT NULL,' + id INT NOT NULL AUTO_INCREMENT,
'secret VARCHAR(64) UNIQUE NOT NULL,' + user_id INT NOT NULL,
'created_at DATETIME NOT NULL DEFAULT NOW(),' + secret VARCHAR(64) UNIQUE NOT NULL,
'used_at DATETIME NOT NULL DEFAULT NOW(),' + created_at DATETIME NOT NULL DEFAULT NOW(),
'ttl INT UNSIGNED NOT NULL,' + used_at DATETIME NOT NULL DEFAULT NOW(),
'PRIMARY KEY (id)' + ttl INT UNSIGNED NOT NULL,
')', connection); PRIMARY KEY (id)
)`, connection);
} }
public async rollback(connection: Connection): Promise<void> { public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE IF EXISTS auth_tokens', connection); await this.query(`DROP TABLE IF EXISTS auth_tokens`, connection);
}
public registerModels(): void {
ModelFactory.register(AuthToken);
} }
} }

View File

@ -1,24 +1,30 @@
import {Connection} from "mysql"; import {Connection} from "mysql";
import Migration from "wms-core/db/Migration"; import Migration from "wms-core/db/Migration";
import ModelFactory from "wms-core/db/ModelFactory";
import FileModel from "../models/FileModel";
export default class CreateFilesTable extends Migration { export default class CreateFilesTable extends Migration {
public async install(connection: Connection): Promise<void> { public async install(connection: Connection): Promise<void> {
await this.query('CREATE TABLE files(' + await this.query(`CREATE TABLE files
'id INT NOT NULL AUTO_INCREMENT,' + (
'user_id INT NOT NULL,' + id INT NOT NULL AUTO_INCREMENT,
'slug VARCHAR(259) UNIQUE NOT NULL,' + user_id INT NOT NULL,
'real_name VARCHAR(259) NOT NULL,' + slug VARCHAR(259) UNIQUE NOT NULL,
'storage_type VARCHAR(64) NOT NULL,' + real_name VARCHAR(259) NOT NULL,
'storage_path VARCHAR(1745) NOT NULL,' + storage_type VARCHAR(64) NOT NULL,
'size INT UNSIGNED NOT NULL,' + storage_path VARCHAR(1745) NOT NULL,
'created_at DATETIME NOT NULL DEFAULT NOW(),' + size INT UNSIGNED NOT NULL,
'ttl INT UNSIGNED NOT NULL,' + created_at DATETIME NOT NULL DEFAULT NOW(),
'PRIMARY KEY (id)' + ttl INT UNSIGNED NOT NULL,
')', connection); PRIMARY KEY (id)
)`, connection);
} }
public async rollback(connection: Connection): Promise<void> { public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE IF EXISTS files', connection); await this.query(`DROP TABLE IF EXISTS files`, connection);
} }
public registerModels(): void {
ModelFactory.register(FileModel);
}
} }

View File

@ -1,20 +1,26 @@
import Migration from "wms-core/db/Migration"; import Migration from "wms-core/db/Migration";
import {Connection} from "mysql"; import {Connection} from "mysql";
import ModelFactory from "wms-core/db/ModelFactory";
import URLRedirect from "../models/URLRedirect";
export default class CreateUrlRedirectsTable extends Migration { export default class CreateUrlRedirectsTable extends Migration {
public async install(connection: Connection): Promise<void> { public async install(connection: Connection): Promise<void> {
await this.query('CREATE TABLE url_redirects(' + await this.query(`CREATE TABLE url_redirects
'id INT NOT NULL AUTO_INCREMENT,' + (
'user_id INT NOT NULL,' + id INT NOT NULL AUTO_INCREMENT,
'slug VARCHAR(259) UNIQUE NOT NULL,' + user_id INT NOT NULL,
'target_url VARCHAR(1745) NOT NULL,' + slug VARCHAR(259) UNIQUE NOT NULL,
'created_at DATETIME NOT NULL DEFAULT NOW(),' + target_url VARCHAR(1745) NOT NULL,
'PRIMARY KEY (id)' + created_at DATETIME NOT NULL DEFAULT NOW(),
')', connection); PRIMARY KEY (id)
)`, connection);
} }
public async rollback(connection: Connection): Promise<void> { public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE IF EXISTS url_redirects', connection); await this.query(`DROP TABLE IF EXISTS url_redirects`, connection);
} }
public registerModels(): void {
ModelFactory.register(URLRedirect);
}
} }

View File

@ -3,10 +3,13 @@ import {Connection} from "mysql";
export default class IncreaseFilesSizeField extends Migration { export default class IncreaseFilesSizeField extends Migration {
public async install(connection: Connection): Promise<void> { public async install(connection: Connection): Promise<void> {
await this.query('ALTER TABLE files MODIFY size BIGINT UNSIGNED', connection); await this.query(`ALTER TABLE files MODIFY size BIGINT UNSIGNED`, connection);
} }
public async rollback(connection: Connection): Promise<void> { public async rollback(connection: Connection): Promise<void> {
await this.query('ALTER TABLE files MODIFY size INT UNSIGNED', connection); await this.query(`ALTER TABLE files MODIFY size INT UNSIGNED`, connection);
}
public registerModels(): void {
} }
} }

View File

@ -1,16 +1,15 @@
import Model from "wms-core/db/Model"; import Model from "wms-core/db/Model";
import AuthProof from "wms-core/auth/AuthProof"; import AuthProof from "wms-core/auth/AuthProof";
import UserEmail from "wms-core/auth/models/UserEmail";
import User from "wms-core/auth/models/User"; import User from "wms-core/auth/models/User";
import Validator from "wms-core/db/Validator";
import {cryptoRandomDictionary} from "wms-core/Utils"; import {cryptoRandomDictionary} from "wms-core/Utils";
export default class AuthToken extends Model implements AuthProof { export default class AuthToken extends Model implements AuthProof<User> {
protected readonly user_id!: number; public id?: number = undefined;
protected readonly secret!: string; protected readonly user_id?: number = undefined;
protected created_at?: Date; protected readonly secret?: string = undefined;
protected used_at?: Date; protected created_at?: Date = undefined;
protected readonly ttl!: number; protected used_at?: Date = undefined;
protected readonly ttl?: number = undefined;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
@ -20,13 +19,10 @@ export default class AuthToken extends Model implements AuthProof {
} }
} }
protected init() { protected init() {
this.addProperty('user_id', new Validator().defined().exists(User, 'id')); this.setValidation('user_id').defined().exists(User, 'id');
this.addProperty('secret', new Validator().defined().between(32, 64)); this.setValidation('secret').defined().between(32, 64);
this.addProperty('created_at', new Validator()); this.setValidation('ttl').defined().min(1).max(5 * 365 * 24 * 3600 /* 5 years */);
this.addProperty('used_at', new Validator());
this.addProperty('ttl', new Validator().defined().min(1).max(5 * 365 * 24 * 3600)); // max 5 years
} }
public use() { public use() {
@ -40,16 +36,10 @@ export default class AuthToken extends Model implements AuthProof {
public getExpirationDate(): Date { public getExpirationDate(): Date {
if (!this.created_at) return new Date(); if (!this.created_at) return new Date();
return new Date(this.created_at.getTime() + this.ttl * 1000); return new Date(this.created_at.getTime() + this.ttl! * 1000);
} }
public async getEmail(): Promise<string> { public async getResource(): Promise<User | null> {
let userEmail = await UserEmail.select().where('user_id', this.user_id).first();
if (!userEmail) throw new Error("Cannot find main user email for user " + this.user_id);
return userEmail.email;
}
public async getUser(): Promise<User | null> {
return await User.getById<User>(this.user_id); return await User.getById<User>(this.user_id);
} }
@ -57,15 +47,11 @@ export default class AuthToken extends Model implements AuthProof {
return true; return true;
} }
public async isOwnedBy(userId: number): Promise<boolean> {
return this.user_id === userId;
}
public async isValid(): Promise<boolean> { public async isValid(): Promise<boolean> {
return new Date().getTime() < this.getExpirationDate().getTime(); return new Date().getTime() < this.getExpirationDate().getTime();
} }
public async revoke(session: Express.Session): Promise<void> { public async revoke(): Promise<void> {
await this.delete(); await this.delete();
} }
} }

View File

@ -1,5 +1,4 @@
import Model from "wms-core/db/Model"; import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import Controller from "wms-core/Controller"; import Controller from "wms-core/Controller";
import config from "config"; import config from "config";
import User from "wms-core/auth/models/User"; import User from "wms-core/auth/models/User";
@ -19,29 +18,29 @@ export default class FileModel extends Model {
return await this.paginate(req, perPage, this.select().where('user_id', user_id)); return await this.paginate(req, perPage, this.select().where('user_id', user_id));
} }
public readonly user_id!: number; public id?: number = undefined;
public readonly slug!: string; public readonly user_id?: number = undefined;
public readonly real_name!: string; public readonly slug?: string = undefined;
public readonly storage_type!: FileStorage; public readonly real_name?: string = undefined;
public readonly storage_path!: string; public readonly storage_type?: FileStorage = undefined;
public readonly size!: number; public readonly storage_path?: string = undefined;
public created_at?: Date; public readonly size?: number = undefined;
public readonly ttl!: number; public created_at?: Date = undefined;
public readonly ttl?: number = undefined;
protected init() { protected init() {
this.addProperty('user_id', new Validator().defined().exists(User, 'id')); this.setValidation('user_id').defined().exists(User, 'id');
this.addProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(FileModel, 'slug').unique(URLRedirect, 'slug')); this.setValidation('slug').defined().minLength(1).maxLength(259).unique(FileModel, 'slug').unique(URLRedirect, 'slug');
this.addProperty('real_name', new Validator().defined().minLength(1).maxLength(259)); this.setValidation('real_name').defined().minLength(1).maxLength(259);
this.addProperty('storage_type', new Validator().defined().maxLength(64)); this.setValidation('storage_type').defined().maxLength(64);
this.addProperty('storage_path', new Validator().defined().maxLength(1745)); this.setValidation('storage_path').defined().maxLength(1745);
this.addProperty('size', new Validator().defined().min(0)); this.setValidation('size').defined().min(0);
this.addProperty('created_at', new Validator()); this.setValidation('ttl').defined().min(0).max(4294967295);
this.addProperty('ttl', new Validator().defined().min(0).max(4294967295));
} }
public getURL(domain: string = config.get<string>('base_url')): string { public getURL(domain: string = config.get<string>('base_url')): string {
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-file', { return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-file', {
slug: this.slug, slug: this.slug!,
}); });
} }
@ -49,7 +48,7 @@ export default class FileModel extends Model {
if (!this.created_at) return new Date(); if (!this.created_at) return new Date();
if (this.ttl === 0) return null; if (this.ttl === 0) return null;
return new Date(this.created_at.getTime() + this.ttl * 1000); return new Date(this.created_at.getTime() + this.ttl! * 1000);
} }
public shouldBeDeleted(): boolean { public shouldBeDeleted(): boolean {

View File

@ -1,5 +1,4 @@
import Model from "wms-core/db/Model"; import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import User from "wms-core/auth/models/User"; import User from "wms-core/auth/models/User";
import FileModel from "./FileModel"; import FileModel from "./FileModel";
import {Request} from "express"; import {Request} from "express";
@ -19,26 +18,25 @@ export default class URLRedirect extends Model {
return await this.paginate(req, perPage, this.select().where('user_id', user_id)); return await this.paginate(req, perPage, this.select().where('user_id', user_id));
} }
public readonly user_id!: number; public id?: number = undefined;
public readonly slug!: string; public readonly user_id?: number = undefined;
public readonly target_url!: string; public readonly slug?: string = undefined;
public created_at?: Date; public readonly target_url?: string = undefined;
public created_at?: Date = undefined;
constructor(data: any) { constructor(data: any) {
super(data); super(data);
} }
protected init(): void { protected init(): void {
this.addProperty('user_id', new Validator().defined().exists(User, 'id')); this.setValidation('user_id').defined().exists(User, 'id');
this.addProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(URLRedirect, 'slug').unique(FileModel, 'slug')); this.setValidation('slug').defined().minLength(1).maxLength(259).unique(URLRedirect, 'slug').unique(FileModel, 'slug');
this.addProperty('target_url', new Validator().defined().maxLength(1745).regexp(/^https?:\/\/.{3,259}?\/?/i)); this.setValidation('target_url').defined().maxLength(1745).regexp(/^https?:\/\/.{3,259}?\/?/i);
this.addProperty('created_at', new Validator());
} }
public getURL(domain: string = config.get<string>('base_url')): string { public getURL(domain: string = config.get<string>('base_url')): string {
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-url', { return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-url', {
slug: this.slug, slug: this.slug!,
}); });
} }
} }

View File

@ -1,41 +0,0 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Review accounts' %}
{% block body %}
<h1>Accounts pending review</h1>
<div class="panel">
<table class="data-table">
<thead>
<tr>
<th class="shrink-col">#</th>
<th>Name</th>
<th>Main email</th>
<th>Registered at</th>
<th class="shrink-col">Action</th>
</tr>
</thead>
<tbody>
{% for user in accounts %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.mainEmail.getOrFail().email }}</td>
<td>{{ user.created_at.toISOString() }}</td>
<td>
<div class="max-content">
<a href="{{ route('approve-account', user.id) }}"
class="button success"><i data-feather="check"></i> Approve</a>
<a href="{{ route('reject-account', user.id) }}"
onclick="return confirm(`This will irrevocably delete the ${user.main_email} account.`)"
class="button danger"><i data-feather="x"></i> Reject</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,21 +0,0 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Backend' %}
{% block body %}
<h1>App administration</h1>
<div class="container">
<div class="panel">
<nav>
<ul>
{% if approval_mode %}
<li>
<a href="{{ route('accounts-approval') }}">Accounts approval ({{ accounts_to_approve }})</a>
</li>
{% endif %}
</ul>
</nav>
</div>
</div>
{% endblock %}

1322
yarn.lock

File diff suppressed because it is too large Load Diff