Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2021-03-30 16:36:22 +02:00
commit 2cb6e028c1
24 changed files with 1744 additions and 1695 deletions

View File

@ -88,6 +88,7 @@
},
"ignorePatterns": [
"jest.config.js",
"scripts/**/*",
"webpack.config.js",
"dist/**/*",
"public/**/*",

View File

@ -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;
}
}
}
}
}

View File

@ -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;

View File

@ -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();
}
});

View File

@ -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,
}

View File

@ -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
}

View File

@ -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
View File

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

View File

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

View File

@ -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() {

View 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);
}
}
}

View 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'));
}
}

View File

@ -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,
});
}
}

View File

@ -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'));
},
});
}

View 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`);
}
}

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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('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>
</ul>
</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">

View File

@ -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 %}

View File

@ -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 %}

2800
yarn.lock

File diff suppressed because it is too large Load Diff