Merge branch 'develop'
This commit is contained in:
commit
2cb6e028c1
@ -88,6 +88,7 @@
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"jest.config.js",
|
||||
"scripts/**/*",
|
||||
"webpack.config.js",
|
||||
"dist/**/*",
|
||||
"public/**/*",
|
||||
|
@ -407,7 +407,7 @@ form {
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
|
||||
.form-field {
|
||||
.form-field:not(.hidden) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 16px auto;
|
||||
@ -927,3 +927,30 @@ td.actions {
|
||||
.table-col-grow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
list-style: none;
|
||||
padding: 8px;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
a, &.active, &.ellipsis {
|
||||
display: block;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 4px;
|
||||
|
||||
line-height: 32px;
|
||||
text-align:center;
|
||||
|
||||
&:hover:not(.active):not(.ellipsis) {
|
||||
background-color: #fff5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,16 +14,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const autogenUrlCheckbox = document.querySelector<HTMLInputElement>('#field-autogen_url');
|
||||
const slugField = document.querySelector<HTMLInputElement>('#field-slug');
|
||||
|
||||
if (expireAfterDaysField) {
|
||||
neverExpireCheckbox?.addEventListener('change', () => {
|
||||
if (expireAfterDaysField && neverExpireCheckbox) {
|
||||
const updateExpireAfterDaysField = () => {
|
||||
expireAfterDaysField.disabled = neverExpireCheckbox.checked;
|
||||
});
|
||||
|
||||
const parent = expireAfterDaysField.parentElement?.parentElement;
|
||||
if (parent) {
|
||||
if (neverExpireCheckbox.checked) {
|
||||
parent.classList.add('hidden');
|
||||
} else {
|
||||
parent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
neverExpireCheckbox.addEventListener('change', updateExpireAfterDaysField);
|
||||
updateExpireAfterDaysField();
|
||||
}
|
||||
|
||||
if (slugField) {
|
||||
autogenUrlCheckbox?.addEventListener('change', () => {
|
||||
if (slugField && autogenUrlCheckbox) {
|
||||
const updateSlugField = () => {
|
||||
slugField.disabled = autogenUrlCheckbox.checked;
|
||||
});
|
||||
|
||||
const parent = slugField.parentElement?.parentElement;
|
||||
if (parent) {
|
||||
if (autogenUrlCheckbox.checked) {
|
||||
parent.classList.add('hidden');
|
||||
} else {
|
||||
parent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
autogenUrlCheckbox.addEventListener('change', updateSlugField);
|
||||
updateSlugField();
|
||||
}
|
||||
|
||||
let uploadForm: UploadForm | undefined;
|
||||
|
@ -5,9 +5,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const autogenUrlCheckbox = document.querySelector<HTMLInputElement>('#field-autogen_url');
|
||||
const slugField = document.querySelector<HTMLInputElement>('#field-slug');
|
||||
|
||||
if (slugField) {
|
||||
autogenUrlCheckbox?.addEventListener('change', () => {
|
||||
if (slugField && autogenUrlCheckbox) {
|
||||
const updateSlugField = () => {
|
||||
slugField.disabled = autogenUrlCheckbox.checked;
|
||||
});
|
||||
|
||||
const parent = slugField.parentElement?.parentElement;
|
||||
if (parent) {
|
||||
if (autogenUrlCheckbox.checked) {
|
||||
parent.classList.add('hidden');
|
||||
} else {
|
||||
parent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
autogenUrlCheckbox.addEventListener('change', updateSlugField);
|
||||
updateSlugField();
|
||||
}
|
||||
});
|
||||
|
@ -54,4 +54,7 @@
|
||||
],
|
||||
default_url_domain_for_files: 0,
|
||||
default_url_domain_for_urls: 1,
|
||||
|
||||
// 1 hour in ms
|
||||
delete_old_files_interval: 3600000,
|
||||
}
|
||||
|
@ -23,8 +23,10 @@
|
||||
domain: 'ily.li',
|
||||
allowed_url_domains: [
|
||||
'ily.li',
|
||||
'gris.li',
|
||||
],
|
||||
default_url_domain_for_files: 0,
|
||||
default_url_domain_for_urls: 1,
|
||||
default_url_domain_for_urls: 0,
|
||||
|
||||
// 1 day in ms
|
||||
delete_old_files_interval: 86400000
|
||||
}
|
||||
|
18
package.json
18
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ily.li",
|
||||
"version": "0.6.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Self-hosted file pusher",
|
||||
"repository": "https://eternae.ink/arisu/ily.li",
|
||||
"author": "Alice Gaudon <alice@gaudon.pro>",
|
||||
@ -8,13 +8,13 @@
|
||||
"main": "dist/main.js",
|
||||
"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 && webpack --mode production",
|
||||
"dev": "yarn prepareSources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"webpack --watch --mode development\" \"maildev\"",
|
||||
"build": "yarn prepare-sources && yarn compile && webpack --mode production",
|
||||
"dev": "yarn prepare-sources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"webpack --watch --mode development\" \"maildev\"",
|
||||
"start": "yarn build && node",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.9.0",
|
||||
@ -34,7 +34,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^4.3.0",
|
||||
"@typescript-eslint/parser": "^4.3.0",
|
||||
"babel-loader": "^8.1.0",
|
||||
"concurrently": "^5.1.0",
|
||||
"concurrently": "^6.0.0",
|
||||
"css-loader": "^5.0.0",
|
||||
"eslint": "^7.10.0",
|
||||
"feather-icons": "^4.28.0",
|
||||
@ -43,14 +43,14 @@
|
||||
"imagemin-gifsicle": "^7.0.0",
|
||||
"imagemin-mozjpeg": "^9.0.0",
|
||||
"imagemin-pngquant": "^9.0.0",
|
||||
"imagemin-svgo": "^8.0.0",
|
||||
"imagemin-svgo": "^9.0.0",
|
||||
"img-loader": "^3.0.1",
|
||||
"jest": "^26.1.0",
|
||||
"maildev": "^1.1.0",
|
||||
"mini-css-extract-plugin": "^1.2.1",
|
||||
"node-sass": "^5.0.0",
|
||||
"nodemon": "^2.0.3",
|
||||
"sass-loader": "^10.0.1",
|
||||
"sass-loader": "^11.0.1",
|
||||
"terser-webpack-plugin": "^5.0.3",
|
||||
"ts-jest": "^26.1.1",
|
||||
"ts-loader": "^8.0.4",
|
||||
|
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});
|
||||
}
|
||||
});
|
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'));
|
10
src/App.ts
10
src/App.ts
@ -42,8 +42,9 @@ import MagicLinkController from "swaf/auth/magic_link/MagicLinkController";
|
||||
import AddUsedToMagicLinksMigration from "swaf/auth/magic_link/AddUsedToMagicLinksMigration";
|
||||
import MakeMagicLinksSessionNotUniqueMigration from "swaf/auth/magic_link/MakeMagicLinksSessionNotUniqueMigration";
|
||||
import AddPasswordToUsersMigration from "swaf/auth/password/AddPasswordToUsersMigration";
|
||||
import DropNameFromUsers from "swaf/auth/migrations/DropNameFromUsers";
|
||||
import DeleteOldFilesJobComponent from "./DeleteOldFilesJobComponent";
|
||||
import packageJson = require('./package.json');
|
||||
import ReplaceTtlWithExpiresAtFilesTable from "./migrations/ReplaceTtlWithExpiresAtFilesTable";
|
||||
|
||||
export default class App extends Application {
|
||||
public constructor(
|
||||
@ -69,7 +70,8 @@ export default class App extends Application {
|
||||
AddUsedToMagicLinksMigration,
|
||||
MakeMagicLinksSessionNotUniqueMigration,
|
||||
AddPasswordToUsersMigration,
|
||||
DropNameFromUsers,
|
||||
DummyMigration,
|
||||
ReplaceTtlWithExpiresAtFilesTable,
|
||||
];
|
||||
}
|
||||
|
||||
@ -115,6 +117,10 @@ export default class App extends Application {
|
||||
|
||||
// WebSocket server
|
||||
this.use(new WebSocketServerComponent(this, this.as(ExpressAppComponent), this.as(RedisComponent)));
|
||||
|
||||
|
||||
// Jobs
|
||||
this.use(new DeleteOldFilesJobComponent());
|
||||
}
|
||||
|
||||
private registerWebSocketListeners() {
|
||||
|
62
src/DeleteOldFilesJobComponent.ts
Normal file
62
src/DeleteOldFilesJobComponent.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import ApplicationComponent from "swaf/ApplicationComponent";
|
||||
import FileModel from "./models/FileModel";
|
||||
import {logger} from "swaf/Logger";
|
||||
import config from "config";
|
||||
import {WhereTest} from "swaf/db/ModelQuery";
|
||||
import MysqlComponent from "swaf/components/MysqlComponent";
|
||||
import FileController from "./controllers/FileController";
|
||||
import Timeout = NodeJS.Timeout;
|
||||
|
||||
export default class DeleteOldFilesJobComponent extends ApplicationComponent {
|
||||
private timeout?: Timeout;
|
||||
private readonly interval: number = config.get<number>('delete_old_files_interval');
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this.getApp().as(MysqlComponent).canServe()) {
|
||||
await this.run();
|
||||
}
|
||||
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (this.timeout) {
|
||||
clearInterval(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.timeout) {
|
||||
clearInterval(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
|
||||
this.timeout = setInterval(() => {
|
||||
if (this.getApp().as(MysqlComponent).canServe()) {
|
||||
this.run().catch(err => logger.error(err));
|
||||
}
|
||||
}, this.interval);
|
||||
logger.info(`Scheduled old file deletion job every ${this.interval}ms`);
|
||||
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const filesToDelete = await FileModel.select()
|
||||
.where('expires_at', new Date(), WhereTest.LE)
|
||||
.get();
|
||||
|
||||
if (filesToDelete.length > 0) {
|
||||
logger.info('Deleting old files...');
|
||||
} else {
|
||||
logger.info('No old file to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
logger.info(`Deleting ${file.id}`);
|
||||
if (!file.shouldBeDeleted()) throw new Error('File should not be deleted.');
|
||||
await FileController.deleteFile(file);
|
||||
}
|
||||
}
|
||||
}
|
@ -3,13 +3,25 @@ import {RequireAuthMiddleware} from "swaf/auth/AuthComponent";
|
||||
import {Request, Response} from "express";
|
||||
import AuthToken from "../models/AuthToken";
|
||||
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "swaf/HttpError";
|
||||
import config from "config";
|
||||
|
||||
export default class AuthTokenController extends Controller {
|
||||
public routes(): void {
|
||||
this.get('/auth-tokens', this.getAuthTokens, 'auth-tokens', RequireAuthMiddleware);
|
||||
this.post('/gen-auth-token', this.postGenAuthToken, 'generate-token', RequireAuthMiddleware);
|
||||
this.post('/revoke-auth-token/:id', this.postRevokeAuthToken, 'revoke-token', RequireAuthMiddleware);
|
||||
}
|
||||
|
||||
public async getAuthTokens(req: Request, res: Response): Promise<void> {
|
||||
const allowedDomains = config.get<string[]>('allowed_url_domains');
|
||||
const user = req.as(RequireAuthMiddleware).getUser();
|
||||
res.render('auth-tokens', {
|
||||
allowed_domains: allowedDomains,
|
||||
default_domain: allowedDomains[config.get<number>('default_url_domain_for_files')],
|
||||
auth_tokens: await AuthToken.select().where('user_id', user.id).get(),
|
||||
});
|
||||
}
|
||||
|
||||
protected async postGenAuthToken(req: Request, res: Response): Promise<void> {
|
||||
const user = req.as(RequireAuthMiddleware).getUser();
|
||||
const authToken = AuthToken.create({
|
||||
@ -18,7 +30,7 @@ export default class AuthTokenController extends Controller {
|
||||
});
|
||||
await authToken.save();
|
||||
req.flash('success', 'Successfully created auth token.');
|
||||
res.redirect(req.getPreviousUrl() || Controller.route('file-upload'));
|
||||
res.redirect(req.getPreviousUrl() || Controller.route('file-uploader'));
|
||||
}
|
||||
|
||||
protected async postRevokeAuthToken(req: Request, res: Response): Promise<void> {
|
||||
@ -34,6 +46,6 @@ export default class AuthTokenController extends Controller {
|
||||
await authToken.delete();
|
||||
|
||||
req.flash('success', 'Successfully deleted auth token.');
|
||||
res.redirect(req.getPreviousUrl() || Controller.route('file-upload'));
|
||||
res.redirect(req.getPreviousUrl() || Controller.route('file-uploader'));
|
||||
}
|
||||
}
|
||||
|
@ -5,8 +5,7 @@ import {BadRequestError, ForbiddenHttpError, ServerError} from "swaf/HttpError";
|
||||
import FileModel from "../models/FileModel";
|
||||
import config from "config";
|
||||
import * as fs from "fs";
|
||||
import AuthToken from "../models/AuthToken";
|
||||
import {IncomingForm} from "formidable";
|
||||
import Formidable from "formidable";
|
||||
import generateSlug from "../SlugGenerator";
|
||||
import {logger} from "swaf/Logger";
|
||||
import FileUploadMiddleware from "swaf/FileUploadMiddleware";
|
||||
@ -14,21 +13,20 @@ import FileUploadMiddleware from "swaf/FileUploadMiddleware";
|
||||
|
||||
export default class FileController extends Controller {
|
||||
public routes(): void {
|
||||
this.get('/files/upload', this.getFileUploader, 'file-upload', RequireAuthMiddleware);
|
||||
this.get('/files/upload/script', this.downloadLinuxScript, 'file-linux-script');
|
||||
this.post('/files/post', this.postFileFrontend, 'post-file-frontend', RequireAuthMiddleware, FileUploadFormMiddleware);
|
||||
this.get('/files/:page([0-9]+)?', this.getFileManager, 'file-manager', RequireAuthMiddleware);
|
||||
this.get('/files/:page([0-9]+)?', this.getFileUploader, 'file-uploader', RequireAuthMiddleware);
|
||||
this.get('/files/upload-script', this.downloadLinuxScript, 'file-linux-script');
|
||||
this.post('/files/upload', this.postFileFrontend, 'post-file-frontend', RequireAuthMiddleware, FileUploadFormMiddleware);
|
||||
this.post('/files/delete/:slug', FileController.deleteFileRoute, 'delete-file-frontend', RequireAuthMiddleware);
|
||||
}
|
||||
|
||||
protected async getFileUploader(req: Request, res: Response): Promise<void> {
|
||||
const allowedDomains = config.get<string[]>('allowed_url_domains');
|
||||
const user = req.as(RequireAuthMiddleware).getUser();
|
||||
res.render('file-upload', {
|
||||
res.render('file-uploader', {
|
||||
max_upload_size: config.get<string>('max_upload_size'),
|
||||
auth_tokens: await AuthToken.select().where('user_id', user.id).get(),
|
||||
allowed_domains: allowedDomains,
|
||||
default_domain: allowedDomains[config.get<number>('default_url_domain_for_files')],
|
||||
files: await FileModel.paginateForUser(req, 25, user.getOrFail('id')),
|
||||
});
|
||||
}
|
||||
|
||||
@ -36,13 +34,6 @@ export default class FileController extends Controller {
|
||||
res.download('assets/files/upload_file.sh', 'upload_file.sh');
|
||||
}
|
||||
|
||||
protected async getFileManager(req: Request, res: Response): Promise<void> {
|
||||
const user = req.as(RequireAuthMiddleware).getUser();
|
||||
res.render('file-manager', {
|
||||
files: await FileModel.paginateForUser(req, 100, user.getOrFail('id')),
|
||||
});
|
||||
}
|
||||
|
||||
protected async postFileFrontend(req: Request, res: Response): Promise<void> {
|
||||
req.body.type = 'file';
|
||||
await FileController.handleFileUpload(
|
||||
@ -100,7 +91,7 @@ export default class FileController extends Controller {
|
||||
html: () => {
|
||||
req.flash('success', 'Upload success!');
|
||||
req.flash('url', file.getURL(domain));
|
||||
res.redirect(Controller.route('file-manager'));
|
||||
res.redirect(Controller.route('file-uploader'));
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -135,7 +126,7 @@ export default class FileController extends Controller {
|
||||
text: () => res.send('success'),
|
||||
html: () => {
|
||||
req.flash('success', 'Successfully deleted file.');
|
||||
res.redirect(Controller.route('file-manager'));
|
||||
res.redirect(Controller.route('file-uploader'));
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -152,11 +143,11 @@ export class FileUploadFormMiddleware extends FileUploadMiddleware {
|
||||
return 'upload';
|
||||
}
|
||||
|
||||
protected makeForm(): IncomingForm {
|
||||
const form = new IncomingForm();
|
||||
form.uploadDir = 'storage/tmp';
|
||||
form.maxFileSize = config.get<number>('max_upload_size') * 1024 * 1024;
|
||||
return form;
|
||||
protected makeForm(): Formidable {
|
||||
return new Formidable({
|
||||
uploadDir: 'storage/tmp',
|
||||
maxFileSize: config.get<number>('max_upload_size') * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,23 +4,21 @@ import URLRedirect from "../models/URLRedirect";
|
||||
import {RequireAuthMiddleware, RequireRequestAuthMiddleware} from "swaf/auth/AuthComponent";
|
||||
import generateSlug from "../SlugGenerator";
|
||||
import config from "config";
|
||||
import AuthToken from "../models/AuthToken";
|
||||
|
||||
export default class URLRedirectController extends Controller {
|
||||
public routes(): void {
|
||||
this.get('/url/shrink', this.getUrlShrinker, 'url-shrinker', RequireAuthMiddleware);
|
||||
this.get('/url/shrink/script', this.downloadLinuxScript, 'url-linux-script');
|
||||
this.get('/url/:page([0-9]+)?', this.getUrlShrinker, 'url-shrinker', RequireAuthMiddleware);
|
||||
this.get('/url/shrink-script', this.downloadLinuxScript, 'url-linux-script');
|
||||
this.post('/url/shrink', this.addUrlFrontend, 'shrink-url', RequireAuthMiddleware);
|
||||
this.get('/urls/:page([0-9]+)?', this.getUrlRedirectManager, 'url-manager', RequireAuthMiddleware);
|
||||
}
|
||||
|
||||
protected async getUrlShrinker(req: Request, res: Response): Promise<void> {
|
||||
const user = req.as(RequireAuthMiddleware).getUser();
|
||||
const allowedDomains = config.get<string[]>('allowed_url_domains');
|
||||
res.render('url-shrinker', {
|
||||
auth_tokens: await AuthToken.select().where('user_id', user.id).get(),
|
||||
allowed_domains: allowedDomains,
|
||||
default_domain: allowedDomains[config.get<number>('default_url_domain_for_urls')],
|
||||
urls: await URLRedirect.paginateForUser(req, 25, user.getOrFail('id')),
|
||||
});
|
||||
}
|
||||
|
||||
@ -28,13 +26,6 @@ export default class URLRedirectController extends Controller {
|
||||
res.download('assets/files/shrink_url.sh', 'shrink_url.sh');
|
||||
}
|
||||
|
||||
protected async getUrlRedirectManager(req: Request, res: Response): Promise<void> {
|
||||
const user = req.as(RequireAuthMiddleware).getUser();
|
||||
res.render('url-manager', {
|
||||
urls: await URLRedirect.paginateForUser(req, 100, user.getOrFail('id')),
|
||||
});
|
||||
}
|
||||
|
||||
protected async addUrlFrontend(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
req.body.type = 'url';
|
||||
await URLRedirectController.addURL(
|
||||
@ -75,7 +66,7 @@ export default class URLRedirectController extends Controller {
|
||||
html: () => {
|
||||
req.flash('success', 'URL shrunk successfully!');
|
||||
req.flash('url', urlRedirect.getURL(domain));
|
||||
res.redirect(Controller.route('url-manager'));
|
||||
res.redirect(Controller.route('url-shrinker'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
46
src/migrations/ReplaceTtlWithExpiresAtFilesTable.ts
Normal file
46
src/migrations/ReplaceTtlWithExpiresAtFilesTable.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import Migration from "swaf/db/Migration";
|
||||
import FileModel from "../models/FileModel";
|
||||
import {logger} from "swaf/Logger";
|
||||
|
||||
export default class ReplaceTtlWithExpiresAtFilesTable extends Migration {
|
||||
public async install(): Promise<void> {
|
||||
await this.query(`ALTER TABLE files
|
||||
ADD COLUMN expires_at DATETIME NULL`);
|
||||
|
||||
const files = await FileModel.select().get();
|
||||
for (const file of files) {
|
||||
logger.debug(file);
|
||||
if (file.created_at && typeof file.ttl === 'number' && file.ttl > 0) {
|
||||
file.expires_at = new Date(file.created_at.getTime() + file.ttl * 1000);
|
||||
}
|
||||
const callbacks: (() => Promise<void>)[] = [];
|
||||
await file.save(this.getCurrentConnection(), callback => {
|
||||
callbacks.push(callback);
|
||||
});
|
||||
for (const c of callbacks) await c();
|
||||
}
|
||||
|
||||
await this.query(`ALTER TABLE files
|
||||
DROP COLUMN ttl`);
|
||||
}
|
||||
|
||||
public async rollback(): Promise<void> {
|
||||
await this.query(`ALTER TABLE files
|
||||
ADD COLUMN ttl INT UNSIGNED NOT NULL DEFAULT 0`);
|
||||
|
||||
const files = await FileModel.select().get();
|
||||
for (const file of files) {
|
||||
if (file.created_at && file.expires_at) {
|
||||
file.ttl = Math.ceil((file.expires_at.getTime() - file.created_at.getTime()) / 1000);
|
||||
}
|
||||
const callbacks: (() => Promise<void>)[] = [];
|
||||
await file.save(this.getCurrentConnection(), callback => {
|
||||
callbacks.push(callback);
|
||||
});
|
||||
for (const c of callbacks) await c();
|
||||
}
|
||||
|
||||
await this.query(`ALTER TABLE files
|
||||
DROP COLUMN expires_at`);
|
||||
}
|
||||
}
|
@ -15,6 +15,8 @@ export default class FileModel extends Model {
|
||||
}
|
||||
|
||||
public static async paginateForUser(req: Request, perPage: number, user_id: number): Promise<FileModel[]> {
|
||||
req.params.sortBy = 'created_at';
|
||||
req.params.sortDirection = 'DESC';
|
||||
return await this.paginate(req, perPage, this.select().where('user_id', user_id));
|
||||
}
|
||||
|
||||
@ -26,16 +28,22 @@ export default class FileModel extends Model {
|
||||
public readonly storage_path?: string = undefined;
|
||||
public readonly size?: number = undefined;
|
||||
public created_at?: Date = undefined;
|
||||
public readonly ttl?: number = undefined;
|
||||
public expires_at?: Date | null = undefined;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public ttl?: number = undefined;
|
||||
|
||||
protected init(): void {
|
||||
this.setValidation('user_id').defined().exists(User, 'id');
|
||||
this.setValidation('slug').defined().minLength(1).maxLength(259).unique(FileModel, 'slug').unique(URLRedirect, 'slug');
|
||||
this.setValidation('slug').defined().minLength(1).maxLength(259)
|
||||
.unique(this, 'slug')
|
||||
.unique(URLRedirect, 'slug');
|
||||
this.setValidation('real_name').defined().minLength(1).maxLength(259);
|
||||
this.setValidation('storage_type').defined().maxLength(64);
|
||||
this.setValidation('storage_path').defined().maxLength(1745);
|
||||
this.setValidation('size').defined().min(0);
|
||||
this.setValidation('ttl').defined().min(0).max(4294967295);
|
||||
}
|
||||
|
||||
public getURL(domain: string = config.get<string>('public_url')): string {
|
||||
@ -44,15 +52,8 @@ export default class FileModel extends Model {
|
||||
});
|
||||
}
|
||||
|
||||
public getExpirationDate(): Date | null {
|
||||
if (!this.created_at) return new Date();
|
||||
if (this.ttl === 0) return null;
|
||||
|
||||
return new Date(this.created_at.getTime() + this.getOrFail('ttl') * 1000);
|
||||
}
|
||||
|
||||
public shouldBeDeleted(): boolean {
|
||||
const expirationDate = this.getExpirationDate();
|
||||
const expirationDate = this.expires_at;
|
||||
if (!expirationDate) return false;
|
||||
return new Date().getTime() >= expirationDate.getTime();
|
||||
}
|
||||
|
@ -15,6 +15,8 @@ export default class URLRedirect extends Model {
|
||||
}
|
||||
|
||||
public static async paginateForUser(req: Request, perPage: number, user_id: number): Promise<URLRedirect[]> {
|
||||
req.params.sortBy = 'created_at';
|
||||
req.params.sortDirection = 'DESC';
|
||||
return await this.paginate(req, perPage, this.select().where('user_id', user_id));
|
||||
}
|
||||
|
||||
@ -26,7 +28,9 @@ export default class URLRedirect extends Model {
|
||||
|
||||
protected init(): void {
|
||||
this.setValidation('user_id').defined().exists(User, 'id');
|
||||
this.setValidation('slug').defined().minLength(1).maxLength(259).unique(URLRedirect, 'slug').unique(FileModel, 'slug');
|
||||
this.setValidation('slug').defined().minLength(1).maxLength(259)
|
||||
.unique(this, 'slug')
|
||||
.unique(FileModel, 'slug');
|
||||
this.setValidation('target_url').defined().maxLength(1745).regexp(/^https?:\/\/.{3,259}?\/?/i);
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,53 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = app.name + ' - Auth tokens' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<section class="panel">
|
||||
<h2><i data-feather="key"></i> Auth tokens</h2>
|
||||
<form action="{{ route('generate-token') }}" method="POST">
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
|
||||
<button type="submit"><i data-feather="plus"></i> Generate a new token</button>
|
||||
</form>
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th class="table-col-grow">Secret</th>
|
||||
<th>Created at</th>
|
||||
<th>Last used at</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for token in auth_tokens %}
|
||||
<tr>
|
||||
<td>{{ token.id }}</td>
|
||||
<td>
|
||||
<div class="copyable-text">
|
||||
<div class="content">{{ token.secret }}</div>
|
||||
<button class="copy-button"><i data-feather="copy"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ token.created_at.toISOString() }}</td>
|
||||
<td>{{ token.used_at.toISOString() }}</td>
|
||||
<td class="actions">
|
||||
<form action="{{ route('revoke-token', token.id) }}" method="POST">
|
||||
<button class="button danger"><i data-feather="trash"></i> <span class="tip">Revoke</span></button>
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="panel">
|
||||
<h2><i data-feather="tool"></i> Setup a desktop utility</h2>
|
||||
<p>There may be a desktop client at some point. For now, if you're an advanced user, you can setup
|
||||
@ -32,8 +81,8 @@
|
||||
<p>Examples:</p>
|
||||
<pre>upload_file.sh path/to/file</pre>
|
||||
<pre>upload_file.sh path/to/file my_very_important_file.png</pre>
|
||||
<pre>shrink_url.sh https://gitlab.com/ArisuOngaku/ilyli</pre>
|
||||
<pre>shrink_url.sh https://gitlab.com/ArisuOngaku/ilyli repo</pre>
|
||||
<pre>shrink_url.sh https://eternae.ink/ashpie/ily.li</pre>
|
||||
<pre>shrink_url.sh https://eternae.ink/ashpie/ily.li repo</pre>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
@ -114,46 +163,5 @@
|
||||
<p>For examples with curl, please download and review the scripts above.</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2><i data-feather="key"></i> Auth tokens</h2>
|
||||
<form action="{{ route('generate-token') }}" method="POST">
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
|
||||
<button type="submit"><i data-feather="plus"></i> Generate a new token</button>
|
||||
</form>
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th class="table-col-grow">Secret</th>
|
||||
<th>Created at</th>
|
||||
<th>Last used at</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for token in auth_tokens %}
|
||||
<tr>
|
||||
<td>{{ token.id }}</td>
|
||||
<td>
|
||||
<div class="copyable-text">
|
||||
<div class="content">{{ token.secret }}</div>
|
||||
<button class="copy-button"><i data-feather="copy"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ token.created_at.toISOString() }}</td>
|
||||
<td>{{ token.used_at.toISOString() }}</td>
|
||||
<td class="actions">
|
||||
<form action="{{ route('revoke-token', token.id) }}" method="POST">
|
||||
<button class="button danger"><i data-feather="trash"></i> <span class="tip">Revoke</span></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,58 +0,0 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = app.name + ' - File manager' %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/fm.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>File manager</h1>
|
||||
<p>You're their manager, please be nice with them.</p>
|
||||
|
||||
<div class="container">
|
||||
<section class="panel">
|
||||
<h2><i data-feather="folder"></i> File list</h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th class="table-col-grow">URL</th>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Expires at</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for file in files %}
|
||||
<tr>
|
||||
<td>{{ file.id }}</td>
|
||||
<td>
|
||||
<div class="copyable-text">
|
||||
<a class="content" href="{{ file.getURL() }}" target="_blank">{{ file.getURL() }}</a>
|
||||
<button class="copy-button"><i data-feather="copy"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
<td><pre>{{ file.real_name }}</pre></td>
|
||||
<td>{{ (file.size / (1024 * 1024)).toFixed(2) }}MB</td>
|
||||
{% set expires_at = file.getExpirationDate() %}
|
||||
<td>{% if expires_at %}{{ expires_at.toISOString() }}{% else %}Never{% endif %}</td>
|
||||
<td class="actions">
|
||||
{% if file.shouldBeDeleted() %}
|
||||
Pending deletion
|
||||
{% else %}
|
||||
<form action="{{ route('delete-file-frontend', file.slug) }}" method="post">
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
<button class="button danger"><i data-feather="trash"></i> <span class="tip">Delete</span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,6 +1,6 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = app.name + ' - File upload' %}
|
||||
{% set title = app.name + ' - Upload file' %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/fm.js"></script>
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
{% block body %}
|
||||
<h1>Upload files</h1>
|
||||
<p>(except illegal ones)</p>
|
||||
<p>You are responsible for the files that you upload.</p>
|
||||
|
||||
<div class="container">
|
||||
<section class="panel">
|
||||
@ -50,7 +50,54 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% include 'desktop-utility.njk' %}
|
||||
|
||||
<section class="panel">
|
||||
<h2><i data-feather="folder"></i> File list</h2>
|
||||
|
||||
{{ macros.paginate(files.pagination, 'file-uploader', 3) }}
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th class="table-col-grow">URL</th>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Expires at</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for file in files %}
|
||||
<tr>
|
||||
<td>{{ file.id }}</td>
|
||||
<td>
|
||||
<div class="copyable-text">
|
||||
<a class="content" href="{{ file.getURL() }}" target="_blank">{{ file.getURL() }}</a>
|
||||
<button class="copy-button"><i data-feather="copy"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
<td><pre>{{ file.real_name }}</pre></td>
|
||||
<td>{{ (file.size / (1024 * 1024)).toFixed(2) }}MB</td>
|
||||
<td>{% if file.expires_at %}{{ file.expires_at.toISOString() }}{% else %}Never{% endif %}</td>
|
||||
<td class="actions">
|
||||
{% if file.shouldBeDeleted() %}
|
||||
Pending deletion
|
||||
{% else %}
|
||||
<form action="{{ route('delete-file-frontend', file.slug) }}" method="post">
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
<button class="button danger"><i data-feather="trash"></i> <span class="tip">Delete</span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{ macros.paginate(files.pagination, 'file-uploader', 3) }}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
@ -18,21 +18,14 @@
|
||||
<ul id="main-menu">
|
||||
<li><a href="{{ route('about') }}"><i data-feather="info"></i> <span class="tip">About</span></a></li>
|
||||
{% if user %}
|
||||
<li><a href="{{ route('file-manager') }}"><i data-feather="folder"></i> <span class="tip">File manager</span></a>
|
||||
<ul class="dropdown">
|
||||
<li><a href="{{ route('file-upload') }}"><i data-feather="upload"></i> <span class="tip">Upload file</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="{{ route('url-manager') }}"><i data-feather="link"></i> <span class="tip">URL manager</span></a>
|
||||
<ul class="dropdown">
|
||||
<li><a href="{{ route('url-shrinker') }}"><i data-feather="crosshair"></i> <span class="tip">Shrink URL</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="{{ route('file-uploader') }}"><i data-feather="upload"></i> <span class="tip">Upload file</span></a></li>
|
||||
<li><a href="{{ route('url-shrinker') }}"><i data-feather="crosshair"></i> <span class="tip">Shrink URL</span></a></li>
|
||||
{% if user.is_admin %}
|
||||
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ route('account') }}"><i data-feather="user"></i> <span class="tip">{{ user.name | default('Account') }}</span></a>
|
||||
<ul class="dropdown">
|
||||
<li><a href="{{ route('auth-tokens') }}"><i data-feather="key"></i> <span class="tip">Auth tokens</span></a></li>
|
||||
<li>
|
||||
<hr>
|
||||
<form action="{{ route('logout') }}?{{ querystring.stringify({redirect_uri: '/'}) }}" method="POST">
|
||||
|
@ -1,40 +0,0 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = app.name + ' - URL manager' %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>URL manager</h1>
|
||||
<p>These are permanent.</p>
|
||||
|
||||
<div class="container">
|
||||
<section class="panel">
|
||||
<h2><i data-feather="link"></i> URL list</h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th class="table-col-grow">URL</th>
|
||||
<th>Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td>{{ url.id }}</td>
|
||||
<td>
|
||||
<div class="copyable-text">
|
||||
<a class="content" href="{{ url.getURL() }}" target="_blank">{{ url.getURL() }}</a>
|
||||
<button class="copy-button"><i data-feather="copy"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
<td><pre>{{ url.target_url }}</pre></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,6 +1,6 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = app.name + ' - URL shrinker' %}
|
||||
{% set title = app.name + ' - Shrink URL' %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/url-shrinker.js"></script>
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
{% block body %}
|
||||
<h1>Shrink URLs</h1>
|
||||
<p>(no phishing allowed)</p>
|
||||
<p>For security reasons, shrinked URLs cannot be deleted.</p>
|
||||
|
||||
<div class="container">
|
||||
<section class="panel">
|
||||
@ -34,7 +34,39 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% include 'desktop-utility.njk' %}
|
||||
|
||||
<section class="panel">
|
||||
<h2><i data-feather="link"></i> URL list</h2>
|
||||
|
||||
{{ macros.paginate(urls.pagination, 'url-shrinker', 3) }}
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th class="table-col-grow">URL</th>
|
||||
<th>Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td>{{ url.id }}</td>
|
||||
<td>
|
||||
<div class="copyable-text">
|
||||
<a class="content" href="{{ url.getURL() }}" target="_blank">{{ url.getURL() }}</a>
|
||||
<button class="copy-button"><i data-feather="copy"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
<td><pre>{{ url.target_url }}</pre></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{ macros.paginate(urls.pagination, 'url-shrinker', 3) }}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
Loading…
Reference in New Issue
Block a user