diff --git a/config/default.ts b/config/default.ts index 512958f..9160d20 100644 --- a/config/default.ts +++ b/config/default.ts @@ -16,4 +16,6 @@ export default Object.assign(require("wms-core/config/default").default, { }, gitlab_webhook_token: 'secret', newlyGeneratedSlugSize: 3, + default_file_ttl: 30, // 30 seconds + max_upload_size: '100mb', }); \ No newline at end of file diff --git a/config/production.ts b/config/production.ts index d6432f6..5aff414 100644 --- a/config/production.ts +++ b/config/production.ts @@ -14,4 +14,5 @@ export default Object.assign(require("wms-core/config/production").default, { }, gitlab_webhook_token: 'CHANGEME', newlyGeneratedSlugSize: 5, + default_file_ttl: 30 * 24 * 3600, // 30 days }); \ No newline at end of file diff --git a/src/App.ts b/src/App.ts index fb5b038..e8f4dba 100644 --- a/src/App.ts +++ b/src/App.ts @@ -125,10 +125,10 @@ export default class App extends Application { // Core functionality this.use(new MailController()); - // Other functionality - this.use(new FileController()); - // Semi-static this.use(new AboutController()); + + // Global slug + this.use(new FileController()); } } \ No newline at end of file diff --git a/src/controllers/FileController.ts b/src/controllers/FileController.ts index 1e17c5d..7f7ccc7 100644 --- a/src/controllers/FileController.ts +++ b/src/controllers/FileController.ts @@ -1,31 +1,35 @@ import Controller from "wms-core/Controller"; import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent"; -import {Request, Response} from "express"; +import express, {Request, Response} from "express"; import {BadRequestError, NotFoundHttpError, ServerError} from "wms-core/HttpError"; import FileModel from "../models/FileModel"; import fileUpload from "express-fileupload"; import {cryptoRandomDictionary} from "wms-core/Utils"; import config from "config"; - -const FILE_UPLOAD_MIDDLEWARE = fileUpload({ - tempFileDir: 'storage/tmp', - useTempFiles: true, - abortOnLimit: true, - createParentPath: true, -}); +import * as fs from "fs"; const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; export default class FileController extends Controller { routes(): void { - this.get('/f/:slug', this.downloadFile, 'get-file'); - this.post('/f', this.postFile, 'post-file', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_MIDDLEWARE); - this.put('/f/:slug', this.putFile, 'put-file', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_MIDDLEWARE); - this.delete('/f/:slug', this.deleteFile, 'delete-file', REQUIRE_AUTH_MIDDLEWARE); + this.get('/files/:page?', this.getFileManager, 'file-manager', REQUIRE_AUTH_MIDDLEWARE); + this.get('/files/delete/:slug', this.deleteFile, 'delete-file-frontend', REQUIRE_AUTH_MIDDLEWARE); + + this.get('/:slug', this.downloadFile, 'get-file'); + this.post('/', this.postFile, 'post-file', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_MAX_SIZE_MIDDLEWARE, FILE_UPLOAD_MIDDLEWARE); + this.put('/:slug', this.putFile, 'put-file', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_MAX_SIZE_MIDDLEWARE, FILE_UPLOAD_MIDDLEWARE); + this.delete('/:slug', this.deleteFile, 'delete-file', REQUIRE_AUTH_MIDDLEWARE); + } + + protected async getFileManager(req: Request, res: Response): Promise { + res.render('file-manager', { + files: await FileModel.paginate(req, 100), + max_upload_size: config.get('max_upload_size'), + }); } protected async downloadFile(req: Request, res: Response): Promise { const file = await FileModel.getBySlug(req.params.slug); - if (!file) throw new NotFoundHttpError('File', req.url); + if (!file || file.shouldBeDeleted()) throw new NotFoundHttpError('File', req.url); switch (file.storage_type) { case 'local': @@ -37,7 +41,7 @@ export default class FileController extends Controller { } protected async postFile(req: Request, res: Response): Promise { - await this.handleFileUpload(await this.generateSlug(10), req, res); + await this.handleFileUpload(req.body.slug || await this.generateSlug(10), req, res); } protected async putFile(req: Request, res: Response): Promise { @@ -63,6 +67,7 @@ export default class FileController extends Controller { real_name: upload.name, storage_type: 'local', storage_path: 'storage/uploads/' + slug, + ttl: req.body.ttl !== undefined ? parseInt(req.body.ttl) : config.get('default_file_ttl'), }); await file.save(); @@ -73,13 +78,36 @@ export default class FileController extends Controller { url: file.getURL(), }), text: () => res.send(file.getURL()), - html: () => res.render('upload_success', {url: file.getURL()}), + html: () => { + req.flash('success', 'Upload success! ' + file.getURL()); + res.redirectBack('/'); + }, }); } protected async deleteFile(req: Request, res: Response): Promise { const file = await FileModel.getBySlug(req.params.slug); if (!file) throw new NotFoundHttpError('File', req.url); + + switch (file.storage_type) { + case 'local': + await file.delete(); + fs.unlinkSync(file.storage_path); + break; + default: + throw new ServerError(`This file cannot be deleted. Deletion protocol for ${file.storage_type} storage type not implemented.`); + } + + res.format({ + json: () => res.json({ + status: 'success', + }), + text: () => res.send('success'), + html: () => { + req.flash('success', 'Successfully deleted file.'); + res.redirectBack('/'); + }, + }); } private async generateSlug(tries: number): Promise { @@ -93,4 +121,13 @@ export default class FileController extends Controller { } while (i < tries) throw new ServerError('Failed to generate slug; newly generated slug size should be increased by 1.'); } -} \ No newline at end of file +} + +const FILE_UPLOAD_MAX_SIZE_MIDDLEWARE = express.urlencoded({limit: config.get('max_upload_size')}); + +const FILE_UPLOAD_MIDDLEWARE = fileUpload({ + tempFileDir: 'storage/tmp', + useTempFiles: true, + abortOnLimit: true, + createParentPath: true, +}); diff --git a/src/migrations/CreateAuthTokensTable.ts b/src/migrations/CreateAuthTokensTable.ts index 56032a3..c43a937 100644 --- a/src/migrations/CreateAuthTokensTable.ts +++ b/src/migrations/CreateAuthTokensTable.ts @@ -8,7 +8,7 @@ export default class CreateAuthTokensTable extends Migration { 'user_id INT NOT NULL,' + 'secret VARCHAR(64) UNIQUE NOT NULL,' + 'created_at DATETIME NOT NULL DEFAULT NOW(),' + - 'ttl INT NOT NULL,' + + 'ttl INT UNSIGNED NOT NULL,' + 'PRIMARY KEY (id)' + ')', connection); } diff --git a/src/migrations/CreateFilesTable.ts b/src/migrations/CreateFilesTable.ts index fe20a4e..6d575db 100644 --- a/src/migrations/CreateFilesTable.ts +++ b/src/migrations/CreateFilesTable.ts @@ -9,6 +9,8 @@ export default class CreateFilesTable extends Migration { 'real_name VARCHAR(259) NOT NULL,' + 'storage_type VARCHAR(64) NOT NULL,' + 'storage_path VARCHAR(1745) NOT NULL,' + + 'created_at DATETIME NOT NULL DEFAULT NOW(),' + + 'ttl INT UNSIGNED NOT NULL,' + 'PRIMARY KEY (id)' + ')', connection); } diff --git a/src/models/AuthToken.ts b/src/models/AuthToken.ts index 5cb1251..47532dc 100644 --- a/src/models/AuthToken.ts +++ b/src/models/AuthToken.ts @@ -26,7 +26,7 @@ export default class AuthToken extends Model implements AuthProof { public getExpirationDate(): Date { if (!this.created_at) return new Date(); - return new Date(this.created_at.getTime() + this.ttl); + return new Date(this.created_at.getTime() + this.ttl * 1000); } public async getEmail(): Promise { diff --git a/src/models/FileModel.ts b/src/models/FileModel.ts index 4dfde5a..20c1dd5 100644 --- a/src/models/FileModel.ts +++ b/src/models/FileModel.ts @@ -17,12 +17,16 @@ export default class FileModel extends Model { public readonly real_name!: string; public readonly storage_type!: FileStorage; public readonly storage_path!: string; + public created_at?: Date; + public readonly ttl!: number; protected defineProperties() { this.defineProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(this, 'slug')); this.defineProperty('real_name', new Validator().defined().minLength(1).maxLength(259)); this.defineProperty('storage_type', new Validator().defined().maxLength(64)); this.defineProperty('storage_path', new Validator().defined().maxLength(1745)); + this.defineProperty('created_at', new Validator()); + this.defineProperty('ttl', new Validator().defined().min(0).max(4294967295)); } public getURL(): string { @@ -30,6 +34,16 @@ export default class FileModel extends Model { slug: this.slug, }); } + + public getExpirationDate(): Date { + if (!this.created_at) return new Date(); + + return new Date(this.created_at.getTime() + this.ttl * 1000); + } + + public shouldBeDeleted(): boolean { + return new Date().getTime() >= this.getExpirationDate().getTime(); + } } export type FileStorage = 'local'; diff --git a/views/file-manager.njk b/views/file-manager.njk new file mode 100644 index 0000000..df4b371 --- /dev/null +++ b/views/file-manager.njk @@ -0,0 +1,23 @@ +{% extends 'layouts/base.njk' %} + +{% set title = 'ily.li - File manager' %} + +{% block body %} +

File manager

+

You're their manager, please be nice with them.

+ +
+
+

Upload a file

+ +
+ {{ macros.field(_locals, 'file', 'upload', '', 'Choose wisely', 'The maximum upload size is' + max_upload_size, validation_attributes='required') }} +
+
+ +
+

File list

+ +
+
+{% endblock %} \ No newline at end of file