Add URL shortening

This commit is contained in:
Alice Gaudon 2020-07-06 10:49:18 +02:00
parent e34c0d46db
commit 9b5a0b2824
17 changed files with 498 additions and 191 deletions

View File

@ -2,6 +2,7 @@
"bundles": { "bundles": {
"app": "js/app.js", "app": "js/app.js",
"fm": "js/fm.js", "fm": "js/fm.js",
"url-shortener": "js/url-shortener.js",
"layout": "sass/layout.scss", "layout": "sass/layout.scss",
"error": "sass/error.scss", "error": "sass/error.scss",
"logo": "img/logo.svg", "logo": "img/logo.svg",

17
assets/files/shorten_url.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/sh
target_url=$1
slug=$2
token=$(cat "$HOME/.ily_token")
if test -z "$slug"; then
method=POST
else
method=PUT
fi
curl -X $method -H 'Accept: text/plain' \
-H "Authorization: $token" \
-F 'type=url' \
-F "target_url=$target_url" \
"https://ily.li/$slug"

View File

@ -1,20 +1,18 @@
#!/bin/sh #!/bin/sh
local_file=$1 local_file=$1
token=$(cat $HOME/.ily_token) slug=$2
token=$(cat "$HOME/.ily_token")
if test -z "$2"; then if test -z "$slug"; then
curl -X POST -H 'Accept: text/plain' \ method=POST
-H "Authorization: $token" \
-F 'type=file' \
-F 'upload=@'$local_file \
-F 'ttl=2592000' \
https://ily.li/
else else
curl -X PUT -H 'Accept: text/plain' \ method=PUT
-H "Authorization: $token" \
-F 'type=file' \
-F 'upload=@'$local_file \
-F 'ttl=2592000' \
"https://ily.li/$2"
fi fi
curl -X $method -H 'Accept: text/plain' \
-H "Authorization: $token" \
-F 'type=file' \
-F "upload=@$local_file" \
-F 'ttl=2592000' \
"https://ily.li/$slug"

View File

@ -0,0 +1,11 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('url-shortener-form');
if (!form) return;
const autogenUrlCheckbox = document.getElementById('field-autogen_url');
const slugField = document.getElementById('field-slug');
autogenUrlCheckbox.addEventListener('change', () => {
slugField.disabled = autogenUrlCheckbox.checked;
});
});

View File

@ -36,6 +36,9 @@ 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 BackendController from "./controllers/BackendController";
import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable";
import AuthTokenController from "./controllers/AuthTokenController";
import URLRedirectController from "./controllers/URLRedirectController";
export default class App extends Application { export default class App extends Application {
private readonly port: number; private readonly port: number;
@ -56,6 +59,7 @@ export default class App extends Application {
CreateFilesTable, CreateFilesTable,
IncreaseFilesSizeField, IncreaseFilesSizeField,
AddApprovedFieldToUsersTable, AddApprovedFieldToUsersTable,
CreateUrlRedirectsTable,
]; ];
} }
@ -136,10 +140,14 @@ export default class App extends Application {
// Core functionality // Core functionality
this.use(new MailController()); this.use(new MailController());
// Other functionality
this.use(new AuthTokenController());
// Semi-static // Semi-static
this.use(new AboutController()); this.use(new AboutController());
// Global slug // Global slug
this.use(new FileController()); this.use(new FileController());
this.use(new URLRedirectController());
} }
} }

17
src/SlugGenerator.ts Normal file
View File

@ -0,0 +1,17 @@
import {cryptoRandomDictionary} from "wms-core/Utils";
import config from "config";
import FileModel from "./models/FileModel";
import {ServerError} from "wms-core/HttpError";
const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export default async function generateSlug(tries: number): Promise<string> {
let i = 0;
do {
const slug = cryptoRandomDictionary(config.get<number>('newlyGeneratedSlugSize'), SLUG_DICTIONARY);
if (!await FileModel.getBySlug(slug)) {
return slug;
}
i++;
} while (i < tries);
throw new ServerError('Failed to generate slug; newly generated slug size should be increased by 1.');
};

View File

@ -0,0 +1,36 @@
import Controller from "wms-core/Controller";
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import AuthToken from "../models/AuthToken";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "wms-core/HttpError";
export default class AuthTokenController extends Controller {
routes(): void {
this.post('/gen-auth-token', this.postGenAuthToken, 'generate-token', REQUIRE_AUTH_MIDDLEWARE);
this.post('/revoke-auth-token/:id', this.postRevokeAuthToken, 'revoke-token', REQUIRE_AUTH_MIDDLEWARE);
}
protected async postGenAuthToken(req: Request, res: Response): Promise<void> {
const authToken = new AuthToken({
user_id: req.models.user!.id,
ttl: req.body.ttl ? parseInt(req.body.ttl) : 365 * 24 * 3600,
});
await authToken.save();
req.flash('success', 'Successfully created auth token.');
res.redirectBack(Controller.route('file-upload'));
}
protected async postRevokeAuthToken(req: Request, res: Response): Promise<void> {
const id = req.params.id;
if (!id) throw new BadRequestError('Cannot revoke token without an id.', 'Please provide an id.', req.url);
const authToken = await AuthToken.getById<AuthToken>(parseInt(id));
if (!authToken) throw new NotFoundHttpError('Auth token', req.url);
if (!authToken.canDelete(req.models.user!.id!)) throw new ForbiddenHttpError('auth token', req.url);
await authToken.delete();
req.flash('success', 'Successfully deleted auth token.');
res.redirectBack(Controller.route('file-upload'));
}
}

View File

@ -1,27 +1,24 @@
import Controller from "wms-core/Controller"; import Controller from "wms-core/Controller";
import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent"; import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {NextFunction, Request, Response} from "express"; import {NextFunction, Request, Response} from "express";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError, ServerError} from "wms-core/HttpError"; import {BadRequestError, ForbiddenHttpError, ServerError} from "wms-core/HttpError";
import FileModel from "../models/FileModel"; import FileModel from "../models/FileModel";
import {cryptoRandomDictionary} from "wms-core/Utils";
import config from "config"; import config from "config";
import * as fs from "fs"; import * as fs from "fs";
import AuthToken from "../models/AuthToken"; import AuthToken from "../models/AuthToken";
import {IncomingForm} from "formidable"; import {IncomingForm} from "formidable";
import {FILE_UPLOAD_MIDDLEWARE} from "wms-core/components/ExpressAppComponent"; import {FILE_UPLOAD_MIDDLEWARE} from "wms-core/components/ExpressAppComponent";
import Logger from "wms-core/Logger"; import Logger from "wms-core/Logger";
import generateSlug from "../SlugGenerator";
const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export default class FileController extends Controller { export default class FileController extends Controller {
routes(): void { routes(): void {
this.get('/files/upload', this.getFileUploader, 'file-upload', REQUIRE_AUTH_MIDDLEWARE); this.get('/files/upload', this.getFileUploader, 'file-upload', REQUIRE_AUTH_MIDDLEWARE);
this.get('/files/upload/script', this.downloadLinuxScript, 'linux-script'); this.get('/files/upload/script', this.downloadLinuxScript, 'file-linux-script');
this.post('/files/post', this.postFileFrontend, 'post-file-frontend', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE); this.post('/files/post', this.postFileFrontend, 'post-file-frontend', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
this.get('/files/:page([0-9]+)?', this.getFileManager, 'file-manager', REQUIRE_AUTH_MIDDLEWARE); this.get('/files/:page([0-9]+)?', this.getFileManager, 'file-manager', REQUIRE_AUTH_MIDDLEWARE);
this.post('/files/delete/:slug', this.deleteFile, 'delete-file-frontend', REQUIRE_AUTH_MIDDLEWARE); this.post('/files/delete/:slug', this.deleteFile, 'delete-file-frontend', REQUIRE_AUTH_MIDDLEWARE);
this.post('/gen-auth-token', this.postGenAuthToken, 'generate-token', REQUIRE_AUTH_MIDDLEWARE);
this.post('/revoke-auth-token/:id', this.postRevokeAuthToken, 'revoke-token', REQUIRE_AUTH_MIDDLEWARE);
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', this.deleteFile, 'delete-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE); this.delete('/:slug', this.deleteFile, 'delete-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
@ -46,32 +43,9 @@ export default class FileController extends Controller {
}); });
} }
protected async postGenAuthToken(req: Request, res: Response): Promise<void> {
const authToken = new AuthToken({
user_id: req.models.user!.id,
ttl: req.body.ttl ? parseInt(req.body.ttl) : 365 * 24 * 3600,
});
await authToken.save();
req.flash('success', 'Successfully created auth token.');
res.redirectBack(Controller.route('file-upload'));
}
protected async postRevokeAuthToken(req: Request, res: Response): Promise<void> {
const id = req.params.id;
if (!id) throw new BadRequestError('Cannot revoke token without an id.', 'Please provide an id.', req.url);
const authToken = await AuthToken.getById<AuthToken>(parseInt(id));
if (!authToken) throw new NotFoundHttpError('Auth token', req.url);
if (!authToken.canDelete(req.models.user!.id!)) throw new ForbiddenHttpError('auth token', req.url);
await authToken.delete();
req.flash('success', 'Successfully deleted auth token.');
res.redirectBack(Controller.route('file-upload'));
}
protected async postFileFrontend(req: Request, res: Response): Promise<void> { protected async postFileFrontend(req: Request, res: Response): Promise<void> {
await this.handleFileUpload(req.body.autogen_url === undefined && req.body.slug ? req.body.slug : await this.generateSlug(10), req, res); req.body.type = 'file';
await this.handleFileUpload(req.body.autogen_url === undefined && req.body.slug ? req.body.slug : await generateSlug(10), req, res);
} }
protected async downloadFile(req: Request, res: Response, next: NextFunction): Promise<void> { protected async downloadFile(req: Request, res: Response, next: NextFunction): Promise<void> {
@ -96,7 +70,7 @@ export default class FileController extends Controller {
protected async postFile(req: Request, res: Response, next: NextFunction): Promise<void> { protected async postFile(req: Request, res: Response, next: NextFunction): Promise<void> {
if (req.body.type !== 'file') return next(); if (req.body.type !== 'file') return next();
await this.handleFileUpload(req.body.slug || await this.generateSlug(10), req, res); await this.handleFileUpload(req.body.slug || await generateSlug(10), req, res);
} }
protected async putFile(req: Request, res: Response, next: NextFunction): Promise<void> { protected async putFile(req: Request, res: Response, next: NextFunction): Promise<void> {
@ -174,18 +148,6 @@ export default class FileController extends Controller {
}, },
}); });
} }
private async generateSlug(tries: number): Promise<string> {
let i = 0;
do {
const slug = cryptoRandomDictionary(config.get<number>('newlyGeneratedSlugSize'), SLUG_DICTIONARY);
if (!await FileModel.getBySlug(slug)) {
return slug;
}
i++;
} while (i < tries);
throw new ServerError('Failed to generate slug; newly generated slug size should be increased by 1.');
}
} }

View File

@ -0,0 +1,82 @@
import Controller from "wms-core/Controller";
import {NextFunction, Request, Response} from "express";
import URLRedirect from "../models/URLRedirect";
import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import generateSlug from "../SlugGenerator";
import {BadRequestError} from "wms-core/HttpError";
export default class URLRedirectController extends Controller {
routes(): void {
this.get('/url/shorten', this.getURLShortener, 'url-shortener', REQUIRE_AUTH_MIDDLEWARE);
this.get('/url/shorten/script', this.downloadLinuxScript, 'url-linux-script');
this.post('/url/shorten', this.addURLFrontend, 'shorten-url', REQUIRE_AUTH_MIDDLEWARE);
this.get('/urls/:page([0-9]+)?', this.getURLRedirectManager, 'url-manager', REQUIRE_AUTH_MIDDLEWARE);
this.post('/', this.addURL, 'post-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
this.delete('/:slug', this.deleteURL, 'delete-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
this.get('/:slug', this.getURLRedirect, 'get-url');
this.put('/:slug', this.addURL, 'put-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
}
protected async getURLShortener(req: Request, res: Response): Promise<void> {
res.render('url-shortener');
}
protected async downloadLinuxScript(req: Request, res: Response): Promise<void> {
res.download('assets/files/shorten_url.sh', 'shorten_url.sh');
}
protected async getURLRedirectManager(req: Request, res: Response): Promise<void> {
res.render('url-manager', {
urls: await URLRedirect.paginateForUser(req, 100, req.models.user!.id!),
});
}
protected async getURLRedirect(req: Request, res: Response, next: NextFunction): Promise<void> {
const url = await URLRedirect.getBySlug(req.params.slug);
if (!url) return next();
res.redirect(url.target_url, 301);
}
protected async deleteURL(req: Request, res: Response, next: NextFunction): Promise<void> {
const urlRedirect = await URLRedirect.getBySlug(req.params.slug);
if (!urlRedirect) return next();
throw new BadRequestError(
'Deleting url redirects is disabled for security reasons.',
'If you still want to disable the redirection, please contact us via email.',
req.url
);
}
protected async addURLFrontend(req: Request, res: Response, next: NextFunction): Promise<void> {
req.body.type = 'url';
await this.addURL(req, res, next, req.body.autogen_url === undefined && req.body.slug ? req.body.slug : await generateSlug(10));
}
protected async addURL(req: Request, res: Response, next: NextFunction, slug?: string): Promise<void> {
if (req.body.type !== 'url') return next();
slug = slug || req.params.slug || req.body.slug || await generateSlug(10);
const urlRedirect = new URLRedirect({
user_id: req.models.user!.id,
slug: slug,
target_url: req.body.target_url,
});
await urlRedirect.save();
res.format({
json: () => res.json({
url: urlRedirect.getURL(),
}),
text: () => res.send(urlRedirect.getURL()),
html: () => {
req.flash('success', 'URL shortened successfully!');
req.flash('url', urlRedirect.getURL());
res.redirectBack('/');
},
});
}
}

View File

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

44
src/models/URLRedirect.ts Normal file
View File

@ -0,0 +1,44 @@
import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import User from "wms-core/auth/models/User";
import FileModel from "./FileModel";
import {Request} from "express";
import config from "config";
import Controller from "wms-core/Controller";
export default class URLRedirect extends Model {
public static get table(): string {
return 'url_redirects';
}
public static async getBySlug(slug: string): Promise<URLRedirect | null> {
return await this.select().where('slug', slug).first();
}
public static async paginateForUser(req: Request, perPage: number, user_id: number): Promise<URLRedirect[]> {
return await this.paginate(req, perPage, this.select().where('user_id', user_id));
}
public readonly user_id!: number;
public readonly slug!: string;
public readonly target_url!: string;
public created_at?: Date;
constructor(data: any) {
super(data);
}
protected init(): void {
this.addProperty('user_id', new Validator().defined().exists(User, 'id'));
this.addProperty('slug', new Validator().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.addProperty('created_at', new Validator());
}
public getURL(): string {
return config.get<string>('base_url') + Controller.route('get-url', {
slug: this.slug,
});
}
}

119
views/desktop-utility.njk Normal file
View File

@ -0,0 +1,119 @@
<div class="container">
<section class="panel">
<h2>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
scripts/macros.</p>
<hr>
<section>
<h3>First alternative: sh script (native on linux)</h3>
<p>If you have the sh shell on your machine (i.e. you are on linux, git bash on windows...), you can
<a href="{{ route('file-linux-script') }}">download this script</a>.</p>
<p>You must put a valid auth token (generated in the form at the bottom of this page) in a .ily_token
file in your home directory ($HOME/.ily_token).</p>
<p>Examples:</p>
<pre>upload_script.sh path/to/file</pre>
<pre>upload_script.sh path/to/file my_very_important_file.png</pre>
</section>
<hr>
<section>
<h3>Second alternative: implement your own client</h3>
<p>
To upload the file, you must:
</p>
<ul>
<li>Set the "Authorization" HTTP header to an auth token (generate one with the form below)</li>
<li>Make a proper file upload request either with the method "POST" on / (auto-generates a short
url) or "PUT" (choose the target url you want, alphanum)
</li>
</ul>
<table class="data-table">
<thead>
<tr>
<th>Field name</th>
<th>Description</th>
<th>Optional?</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>upload</td>
<td>The file field</td>
<td>No</td>
<td>-</td>
</tr>
<tr>
<td>ttl</td>
<td>How much time (in seconds) to keep the file</td>
<td>Yes</td>
<td>0 (never delete), 30 (delete after 30s)</td>
</tr>
</tbody>
</table>
<p>Example with curl:</p>
<pre>curl -X POST -H 'Accept: application/json' \
-H "Authorization: very_secret_and_personal_token" \
-F 'type=file' \
-F 'upload=@path/to/local/file' \
https://ily.li/</pre>
<pre>curl -X PUT -H 'Accept: application/json' \
-H "Authorization: very_secret_and_personal_token" \
-F 'type=file' \
-F 'upload=@path/to/local/file' \
https://ily.li/my_very_important_file.png</pre>
<pre>curl -X POST -H 'Accept: application/json' \
-H "Authorization: very_secret_and_personal_token" \
-F 'type=file' \
-F 'upload=@path/to/local/file' \
-F 'ttl=30' \
https://ily.li/</pre>
</section>
</section>
</div>
<section class="panel">
<h2>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>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>
<form action="{{ route('revoke-token', token.id) }}" method="POST">
<button class="button danger"><i data-feather="trash"></i> Revoke</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>

View File

@ -41,122 +41,7 @@
<button class="copy-button"><i data-feather="copy"></i></button> <button class="copy-button"><i data-feather="copy"></i></button>
</div> </div>
</section> </section>
<section class="panel">
<h2>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
scripts/macros.</p>
<hr>
<section>
<h3>First alternative: sh script (native on linux)</h3>
<p>If you have the sh shell on your machine (i.e. you are on linux, git bash on windows...), you can
<a href="{{ route('linux-script') }}">download this script</a>.</p>
<p>You must put a valid auth token (generated in the form at the bottom of this page) in a .ily_token file in your home directory ($HOME/.ily_token).</p>
<p>Examples:</p>
<pre>upload_script.sh path/to/file</pre>
<pre>upload_script.sh path/to/file my_very_important_file.png</pre>
</section>
<hr>
<section>
<h3>Second alternative: implement your own client</h3>
<p>
To upload the file, you must:
</p>
<ul>
<li>Set the "Authorization" HTTP header to an auth token (generate one with the form below)</li>
<li>Make a proper file upload request either with the method "POST" on / (auto-generates a short
url) or "PUT" (choose the target url you want, alphanum)
</li>
</ul>
<table class="data-table">
<thead>
<tr>
<th>Field name</th>
<th>Description</th>
<th>Optional?</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>upload</td>
<td>The file field</td>
<td>No</td>
<td>-</td>
</tr>
<tr>
<td>ttl</td>
<td>How much time (in seconds) to keep the file</td>
<td>Yes</td>
<td>0 (never delete), 30 (delete after 30s)</td>
</tr>
</tbody>
</table>
<p>Example with curl:</p>
<pre>curl -X POST -H 'Accept: application/json' \
-H "Authorization: very_secret_and_personal_token" \
-F 'type=file' \
-F 'upload=@path/to/local/file' \
https://ily.li/</pre>
<pre>curl -X PUT -H 'Accept: application/json' \
-H "Authorization: very_secret_and_personal_token" \
-F 'type=file' \
-F 'upload=@path/to/local/file' \
https://ily.li/my_very_important_file.png</pre>
<pre>curl -X POST -H 'Accept: application/json' \
-H "Authorization: very_secret_and_personal_token" \
-F 'type=file' \
-F 'upload=@path/to/local/file' \
-F 'ttl=30' \
https://ily.li/</pre>
</section>
</section>
</div> </div>
<section class="panel"> {% include 'desktop-utility.njk' %}
<h2>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>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>
<form action="{{ route('revoke-token', token.id) }}" method="POST">
<button class="button danger"><i data-feather="trash"></i> Revoke</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %} {% endblock %}

View File

@ -15,10 +15,11 @@
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> {{ app.name }}</a> <a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> {{ app.name }}</a>
<nav> <nav>
<ul> <ul>
<li><a href="{{ route('about') }}"><i data-feather="info"></i> About</a></li>
{% if user %} {% if user %}
<li><a href="{{ route('file-upload') }}"><i data-feather="upload"></i> File uploader</a></li> <li><a href="{{ route('file-upload') }}"><i data-feather="upload"></i> File uploader</a></li>
<li><a href="{{ route('file-manager') }}"><i data-feather="folder"></i> File manager</a></li> <li><a href="{{ route('file-manager') }}"><i data-feather="folder"></i> File manager</a></li>
<li><a href="{{ route('url-shortener') }}"><i data-feather="crosshair"></i> URL shortener</a></li>
<li><a href="{{ route('url-manager') }}"><i data-feather="link"></i> URL manager</a></li>
{% if user.is_admin %} {% if user.is_admin %}
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> Backend</a></li> <li><a href="{{ route('backend') }}"><i data-feather="settings"></i> Backend</a></li>
{% endif %} {% endif %}
@ -47,4 +48,4 @@
</main> </main>
{% endblock %} {% endblock %}
{% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved.{% endblock %} {% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved. | <a href="{{ route('about') }}">About</a>{% endblock %}

44
views/url-manager.njk Normal file
View File

@ -0,0 +1,44 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - URL manager' %}
{% block scripts %}{% endblock %}
{% block body %}
<h1>URL manager</h1>
<p>These are permanent.</p>
<section class="panel">
<h2>URL list</h2>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>URL</th>
<th>Target</th>
<th>Actions</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>{{ url.target_url }}</td>
<td>
<form action="{{ route('delete-file-frontend', url.slug) }}" method="post">
<button class="button danger"><i data-feather="trash"></i> Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

40
views/url-shortener.njk Normal file
View File

@ -0,0 +1,40 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - URL shortener' %}
{% block scripts %}
<script src="/js/url-shortener.js"></script>
{% endblock %}
{% block body %}
<h1>Shorten URLs</h1>
<p>(no phishing allowed)</p>
<div class="container">
<section class="panel">
<h2>Shorten a URL</h2>
<form action="{{ route('shorten-url') }}" method="POST" id="url-shortener-form">
{{ macros.field(_locals, 'text', 'target_url', '', 'Target URL', 'Only valid URLs starting with http:// or https://', validation_attributes='required') }}
{{ macros.field(_locals, 'text', 'slug', '', 'Custom url slug', 'Example: bear sets url to https://ily.li/bear', validation_attributes='disabled') }}
{{ macros.field(_locals, 'checkbox', 'autogen_url', '', 'Generate url automatically', null, validation_attributes='checked') }}
{{ macros.csrf(getCSRFToken) }}
<button type="submit"><i data-feather="link"></i> Shorten URL</button>
</form>
{% set url = flash('url') %}
{% if url | length %}
<div class="copyable-text">
<div class="title">URL</div>
<div class="content">{{ url }}</div>
<button class="copy-button"><i data-feather="copy"></i></button>
</div>
{% endif %}
</section>
</div>
{% include 'desktop-utility.njk' %}
{% endblock %}

View File

@ -770,13 +770,20 @@
"@babel/types" "^7.4.4" "@babel/types" "^7.4.4"
esutils "^2.0.2" esutils "^2.0.2"
"@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": "@babel/runtime@^7.8.4":
version "7.10.3" version "7.10.3"
resolved "https://registry.toot.party/@babel%2fruntime/-/runtime-7.10.3.tgz#670d002655a7c366540c67f6fd3342cd09500364" resolved "https://registry.toot.party/@babel%2fruntime/-/runtime-7.10.3.tgz#670d002655a7c366540c67f6fd3342cd09500364"
integrity sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw== integrity sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.8.7":
version "7.10.4"
resolved "https://registry.toot.party/@babel%2fruntime/-/runtime-7.10.4.tgz#a6724f1a6b8d2f6ea5236dbfe58c7d7ea9c5eb99"
integrity sha512-UpTN5yUJr9b4EX2CnGNWIvER7Ab83ibv0pcvvHc4UOdrBI5jb8bj+32cCwPX6xu0mt2daFNjYhoi+X7beH0RSw==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.1", "@babel/template@^7.10.3", "@babel/template@^7.3.3": "@babel/template@^7.10.1", "@babel/template@^7.10.3", "@babel/template@^7.3.3":
version "7.10.3" version "7.10.3"
resolved "https://registry.toot.party/@babel%2ftemplate/-/template-7.10.3.tgz#4d13bc8e30bf95b0ce9d175d30306f42a2c9a7b8" resolved "https://registry.toot.party/@babel%2ftemplate/-/template-7.10.3.tgz#4d13bc8e30bf95b0ce9d175d30306f42a2c9a7b8"
@ -1270,9 +1277,9 @@
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
"@types/redis@^2.8.18": "@types/redis@^2.8.18":
version "2.8.22" version "2.8.24"
resolved "https://registry.toot.party/@types%2fredis/-/redis-2.8.22.tgz#8935227cbe39080506b625276d64974ddbcb9ea4" resolved "https://registry.toot.party/@types%2fredis/-/redis-2.8.24.tgz#164d1d1b9f628d089e63874b072100c30d9d1c1a"
integrity sha512-O21YLcAtcSzax8wy4CfxMNjIMNf5X2c1pKTXDWLMa2p77Igvy7wuNjWVv+Db93wTvRvLLev6oq3IE7gxNKFZyg== integrity sha512-I5LivNsNdEvBDJnBthjL4YAqD888eSf/k8kM5ROzgRHy7UE7n+ijwFVKqCSM4Ekujfb+3SDz/GbEhXWjnuI04w==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
@ -1290,9 +1297,9 @@
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
"@types/ws@^7.2.4": "@types/ws@^7.2.4":
version "7.2.5" version "7.2.6"
resolved "https://registry.toot.party/@types%2fws/-/ws-7.2.5.tgz#513f28b04a1ea1aa9dc2cad3f26e8e37c88aae49" resolved "https://registry.toot.party/@types%2fws/-/ws-7.2.6.tgz#516cbfb818310f87b43940460e065eb912a4178d"
integrity sha512-4UEih9BI1nBKii385G9id1oFrSkLcClbwtDfcYj8HJLQqZVAtb/42vXVrYvRWCcufNF/a+rZD3MxNwghA7UmCg== integrity sha512-Q07IrQUSNpr+cXU4E4LtkSIBPie5GLZyyMC1QtQYRLWz701+XcoVygGUZgvLqElq1nU4ICldMYPnexlBsg3dqQ==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
@ -1519,7 +1526,7 @@ ajv-keywords@^3.1.0, ajv-keywords@^3.4.1:
resolved "https://registry.toot.party/ajv-keywords/-/ajv-keywords-3.5.0.tgz#5c894537098785926d71e696114a53ce768ed773" resolved "https://registry.toot.party/ajv-keywords/-/ajv-keywords-3.5.0.tgz#5c894537098785926d71e696114a53ce768ed773"
integrity sha512-eyoaac3btgU8eJlvh01En8OCKzRqlLe2G5jDsCr3RiE2uLGMEEB1aaGwVVpwR8M95956tGH6R+9edC++OvzaVw== integrity sha512-eyoaac3btgU8eJlvh01En8OCKzRqlLe2G5jDsCr3RiE2uLGMEEB1aaGwVVpwR8M95956tGH6R+9edC++OvzaVw==
ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.5.5: ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.2:
version "6.12.2" version "6.12.2"
resolved "https://registry.toot.party/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" resolved "https://registry.toot.party/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd"
integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==
@ -1529,6 +1536,16 @@ ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.5.5:
json-schema-traverse "^0.4.1" json-schema-traverse "^0.4.1"
uri-js "^4.2.2" uri-js "^4.2.2"
ajv@^6.5.5:
version "6.12.3"
resolved "https://registry.toot.party/ajv/-/ajv-6.12.3.tgz#18c5af38a111ddeb4f2697bd78d68abc1cabd706"
integrity sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
amdefine@>=0.0.4: amdefine@>=0.0.4:
version "1.0.1" version "1.0.1"
resolved "https://registry.toot.party/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" resolved "https://registry.toot.party/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
@ -6785,9 +6802,9 @@ no-case@^2.2.0:
lower-case "^1.1.1" lower-case "^1.1.1"
node-addon-api@^2.0.0: node-addon-api@^2.0.0:
version "2.0.1" version "2.0.2"
resolved "https://registry.toot.party/node-addon-api/-/node-addon-api-2.0.1.tgz#4fd0931bf6d7e48b219ff3e6abc73cbb0252b7a3" resolved "https://registry.toot.party/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32"
integrity sha512-2WVfwRfIr1AVn3dRq4yRc2Hn35ND+mPJH6inC6bjpYCZVrpXPB4j3T6i//OGVfqVsR1t/X/axRulDsheq4F0LQ== integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==
node-gyp@^3.8.0: node-gyp@^3.8.0:
version "3.8.0" version "3.8.0"
@ -9825,9 +9842,9 @@ widest-line@^3.1.0:
string-width "^4.0.0" string-width "^4.0.0"
wms-core@^0: wms-core@^0:
version "0.10.18" version "0.10.19"
resolved "https://registry.toot.party/wms-core/-/wms-core-0.10.18.tgz#450bf5be19f1a5872230199b04353169e3cfe2aa" resolved "https://registry.toot.party/wms-core/-/wms-core-0.10.19.tgz#d04830bd66ea5ee2e43b7314dfa8c92e6b11697f"
integrity sha512-x4+FgnfovJHLpAKYKmh5+rM9Zovklya8mZC+KnrQ65y2v3vw3i9hDRjXeums5OvyQsBA7X4OObEffTbClLNCTQ== integrity sha512-9DGTKtdKho8FJV5b0k7D6/21zFjOQTbJz4wkvTdgW+EL2iriH9ojFVRaakGqgb3WoxejZFaz14HSYysNAJ4Xsw==
dependencies: dependencies:
"@types/express" "^4.17.6" "@types/express" "^4.17.6"
"@types/express-session" "^1.17.0" "@types/express-session" "^1.17.0"
@ -9901,11 +9918,16 @@ write-file-atomic@^3.0.0:
signal-exit "^3.0.2" signal-exit "^3.0.2"
typedarray-to-buffer "^3.1.5" typedarray-to-buffer "^3.1.5"
ws@^7.0.0, ws@^7.2.3: ws@^7.0.0:
version "7.3.0" version "7.3.0"
resolved "https://registry.toot.party/ws/-/ws-7.3.0.tgz#4b2f7f219b3d3737bc1a2fbf145d825b94d38ffd" resolved "https://registry.toot.party/ws/-/ws-7.3.0.tgz#4b2f7f219b3d3737bc1a2fbf145d825b94d38ffd"
integrity sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w== integrity sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==
ws@^7.2.3:
version "7.3.1"
resolved "https://registry.toot.party/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8"
integrity sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==
xdg-basedir@^4.0.0: xdg-basedir@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.toot.party/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" resolved "https://registry.toot.party/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"