Merge branch 'develop'
This commit is contained in:
commit
5016b46629
@ -2,6 +2,7 @@
|
||||
"bundles": {
|
||||
"app": "js/app.js",
|
||||
"fm": "js/fm.js",
|
||||
"url-shrinker": "js/url-shrinker.js",
|
||||
"layout": "sass/layout.scss",
|
||||
"error": "sass/error.scss",
|
||||
"logo": "img/logo.svg",
|
||||
|
17
assets/files/shrink_url.sh
Executable file
17
assets/files/shrink_url.sh
Executable 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"
|
@ -1,18 +1,18 @@
|
||||
#!/bin/sh
|
||||
|
||||
local_file=$1
|
||||
token=$(cat $HOME/.ily_token)
|
||||
slug=$2
|
||||
token=$(cat "$HOME/.ily_token")
|
||||
|
||||
if test -z "$2"; then
|
||||
curl -X POST -H 'Accept: text/plain' \
|
||||
-H "Authorization: $token" \
|
||||
-F 'upload=@'$local_file \
|
||||
-F 'ttl=2592000' \
|
||||
https://ily.li/
|
||||
if test -z "$slug"; then
|
||||
method=POST
|
||||
else
|
||||
curl -X PUT -H 'Accept: text/plain' \
|
||||
-H "Authorization: $token" \
|
||||
-F 'upload=@'$local_file \
|
||||
-F 'ttl=2592000' \
|
||||
"https://ily.li/$2"
|
||||
method=PUT
|
||||
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"
|
||||
|
@ -2,6 +2,7 @@ import './external_links';
|
||||
import './message_icons';
|
||||
import './forms';
|
||||
import './copyable_text';
|
||||
import './main_menu';
|
||||
|
||||
import '../sass/app.scss';
|
||||
|
||||
|
17
assets/js/main_menu.js
Normal file
17
assets/js/main_menu.js
Normal file
@ -0,0 +1,17 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const menuButton = document.getElementById('menu-button');
|
||||
const mainMenu = document.getElementById('main-menu');
|
||||
|
||||
menuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
mainMenu.classList.toggle('open');
|
||||
});
|
||||
|
||||
mainMenu.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
mainMenu.classList.remove('open');
|
||||
});
|
||||
});
|
11
assets/js/url-shrinker.js
Normal file
11
assets/js/url-shrinker.js
Normal file
@ -0,0 +1,11 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('url-shrink-form');
|
||||
if (!form) return;
|
||||
|
||||
const autogenUrlCheckbox = document.getElementById('field-autogen_url');
|
||||
const slugField = document.getElementById('field-slug');
|
||||
|
||||
autogenUrlCheckbox.addEventListener('change', () => {
|
||||
slugField.disabled = autogenUrlCheckbox.checked;
|
||||
});
|
||||
});
|
@ -25,3 +25,6 @@ $warningColor: desaturate($warningText, 50%);
|
||||
$error: #ff0000;
|
||||
$errorText: darken($error, 30%);
|
||||
$errorColor: desaturate($errorText, 50%);
|
||||
|
||||
// Responsivity
|
||||
$menuLayoutSwitchTreshold: 700px;
|
||||
|
@ -51,7 +51,8 @@ header {
|
||||
}
|
||||
}
|
||||
|
||||
nav ul {
|
||||
nav {
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
@ -63,6 +64,7 @@ header {
|
||||
list-style: none;
|
||||
|
||||
a, span {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@ -71,15 +73,113 @@ header {
|
||||
|
||||
.feather {
|
||||
--icon-size: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.tip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity ease-out 100ms;
|
||||
transition-delay: 150ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
a:hover, a:active {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#menu-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $menuLayoutSwitchTreshold) {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.logo {
|
||||
padding: 0 16px 0 8px;
|
||||
font-size: 24px;
|
||||
|
||||
img {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
#menu-button {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: $headerHeight;
|
||||
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
|
||||
.feather {
|
||||
--icon-size: 28px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
transition: transform ease-out 150ms;
|
||||
|
||||
background-color: $headerBackground;
|
||||
|
||||
&.open {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
li a {
|
||||
.tip {
|
||||
display: block;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $menuLayoutSwitchTreshold) {
|
||||
nav ul li {
|
||||
a .tip {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: max-content;
|
||||
height: 30px;
|
||||
padding: 4px 8px;
|
||||
line-height: 22px;
|
||||
top: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
color: $defaultTextColor;
|
||||
opacity: 0;
|
||||
transition: opacity ease-out 100ms, visibility step-end 150ms;
|
||||
transition-delay: 0ms;
|
||||
background-color: #000;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
&:last-child a .tip {
|
||||
left: unset;
|
||||
right: 4px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
|
@ -33,4 +33,10 @@ export default Object.assign(require("wms-core/config/default").default, {
|
||||
from: 'contact@ily.li',
|
||||
from_name: 'ily.li',
|
||||
},
|
||||
allowed_url_domains: [
|
||||
'localhost:4893',
|
||||
'127.0.0.1:4893',
|
||||
],
|
||||
default_url_domain_for_files: 0,
|
||||
default_url_domain_for_urls: 1,
|
||||
});
|
@ -21,4 +21,10 @@ export default Object.assign(require("wms-core/config/production").default, {
|
||||
secure: true,
|
||||
allow_invalid_tls: false
|
||||
},
|
||||
allowed_url_domains: [
|
||||
'ily.li',
|
||||
'gris.li',
|
||||
],
|
||||
default_url_domain_for_files: 0,
|
||||
default_url_domain_for_urls: 1,
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ily.li",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"description": "Self-hosted file pusher",
|
||||
"repository": "git@gitlab.com:ArisuOngaku/ily.li.git",
|
||||
"author": "Alice Gaudon <alice@gaudon.pro>",
|
||||
|
12
src/App.ts
12
src/App.ts
@ -36,6 +36,10 @@ import CreateFilesTable from "./migrations/CreateFilesTable";
|
||||
import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField";
|
||||
import AddApprovedFieldToUsersTable from "wms-core/auth/migrations/AddApprovedFieldToUsersTable";
|
||||
import BackendController from "./controllers/BackendController";
|
||||
import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable";
|
||||
import AuthTokenController from "./controllers/AuthTokenController";
|
||||
import URLRedirectController from "./controllers/URLRedirectController";
|
||||
import LinkController from "./controllers/LinkController";
|
||||
|
||||
export default class App extends Application {
|
||||
private readonly port: number;
|
||||
@ -56,6 +60,7 @@ export default class App extends Application {
|
||||
CreateFilesTable,
|
||||
IncreaseFilesSizeField,
|
||||
AddApprovedFieldToUsersTable,
|
||||
CreateUrlRedirectsTable,
|
||||
];
|
||||
}
|
||||
|
||||
@ -128,6 +133,9 @@ export default class App extends Application {
|
||||
}
|
||||
|
||||
private registerControllers() {
|
||||
// Multi-domain + vhost
|
||||
this.use(new LinkController());
|
||||
|
||||
// Priority
|
||||
this.use(new AuthController());
|
||||
this.use(new MagicLinkController(this.magicLinkWebSocketListener!));
|
||||
@ -136,10 +144,14 @@ export default class App extends Application {
|
||||
// Core functionality
|
||||
this.use(new MailController());
|
||||
|
||||
// Other functionality
|
||||
this.use(new AuthTokenController());
|
||||
|
||||
// Semi-static
|
||||
this.use(new AboutController());
|
||||
|
||||
// Global slug
|
||||
this.use(new FileController());
|
||||
this.use(new URLRedirectController());
|
||||
}
|
||||
}
|
17
src/SlugGenerator.ts
Normal file
17
src/SlugGenerator.ts
Normal 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.');
|
||||
};
|
36
src/controllers/AuthTokenController.ts
Normal file
36
src/controllers/AuthTokenController.ts
Normal 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'));
|
||||
}
|
||||
}
|
@ -1,37 +1,33 @@
|
||||
import Controller from "wms-core/Controller";
|
||||
import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
|
||||
import {Request, Response} from "express";
|
||||
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError, ServerError} from "wms-core/HttpError";
|
||||
import {NextFunction, Request, Response} from "express";
|
||||
import {BadRequestError, ForbiddenHttpError, ServerError} from "wms-core/HttpError";
|
||||
import FileModel from "../models/FileModel";
|
||||
import {cryptoRandomDictionary} from "wms-core/Utils";
|
||||
import config from "config";
|
||||
import * as fs from "fs";
|
||||
import AuthToken from "../models/AuthToken";
|
||||
import {IncomingForm} from "formidable";
|
||||
import {FILE_UPLOAD_MIDDLEWARE} from "wms-core/components/ExpressAppComponent";
|
||||
import Logger from "wms-core/Logger";
|
||||
import generateSlug from "../SlugGenerator";
|
||||
|
||||
const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
export default class FileController extends Controller {
|
||||
routes(): void {
|
||||
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.get('/files/:page([0-9]+)?', this.getFileManager, 'file-manager', REQUIRE_AUTH_MIDDLEWARE);
|
||||
this.get('/files/delete/:slug', this.deleteFile, 'delete-file-frontend', REQUIRE_AUTH_MIDDLEWARE);
|
||||
this.post('/gen-auth-token', this.postGenAuthToken, 'generate-token', REQUIRE_AUTH_MIDDLEWARE);
|
||||
this.get('/revoke-auth-token/:id', this.getRevokeAuthToken, 'revoke-token', REQUIRE_AUTH_MIDDLEWARE);
|
||||
|
||||
this.post('/', this.postFile, 'post-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
|
||||
this.delete('/delete/:slug', this.deleteFile, 'delete-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
|
||||
this.get('/:slug', this.downloadFile, 'get-file');
|
||||
this.put('/:slug', this.putFile, 'put-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
|
||||
this.post('/files/delete/:slug', FileController.deleteFile, 'delete-file-frontend', REQUIRE_AUTH_MIDDLEWARE);
|
||||
}
|
||||
|
||||
protected async getFileUploader(req: Request, res: Response): Promise<void> {
|
||||
const allowedDomains = config.get<string[]>('allowed_url_domains');
|
||||
res.render('file-upload', {
|
||||
max_upload_size: config.get<string>('max_upload_size'),
|
||||
auth_tokens: await AuthToken.select().where('user_id', req.models.user!.id!),
|
||||
allowed_domains: allowedDomains,
|
||||
default_domain: allowedDomains[config.get<number>('default_url_domain_for_files')],
|
||||
});
|
||||
}
|
||||
|
||||
@ -45,59 +41,12 @@ 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 getRevokeAuthToken(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> {
|
||||
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 FileController.handleFileUpload(req.body.autogen_url === undefined && req.body.slug ? req.body.slug : await generateSlug(10), req, res);
|
||||
}
|
||||
|
||||
protected async downloadFile(req: Request, res: Response): Promise<void> {
|
||||
const file = await FileModel.getBySlug(req.params.slug);
|
||||
if (!file || file.shouldBeDeleted()) throw new NotFoundHttpError('File', req.url);
|
||||
|
||||
switch (file.storage_type) {
|
||||
case 'local':
|
||||
res.download(file.storage_path, file.real_name);
|
||||
break;
|
||||
default:
|
||||
throw new ServerError(`This file cannot be served. Download protocol for ${file.storage_type} storage type not implemented.`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async postFile(req: Request, res: Response): Promise<void> {
|
||||
await this.handleFileUpload(req.body.slug || await this.generateSlug(10), req, res);
|
||||
}
|
||||
|
||||
protected async putFile(req: Request, res: Response): Promise<void> {
|
||||
const slug = req.params.slug;
|
||||
if (!slug) throw new BadRequestError('Cannot put without a slug.', 'Either provide a slug or use POST method instead.', req.url);
|
||||
|
||||
await this.handleFileUpload(slug, req, res);
|
||||
}
|
||||
|
||||
protected async handleFileUpload(slug: string, req: Request, res: Response): Promise<void> {
|
||||
public static async handleFileUpload(slug: string, req: Request, res: Response): Promise<void> {
|
||||
// Check for file upload
|
||||
if (!req.files || !req.files['upload']) {
|
||||
throw new BadRequestError('No file received.', 'You must upload exactly one (1) file.', req.url);
|
||||
@ -124,24 +73,26 @@ export default class FileController extends Controller {
|
||||
await file.save();
|
||||
fs.renameSync(upload.path, file.storage_path);
|
||||
|
||||
const domain = req.body.url_domain || config.get<string[]>('allowed_url_domains')[config.get<number>('default_url_domain_for_files')];
|
||||
res.format({
|
||||
json: () => res.json({
|
||||
url: file.getURL(),
|
||||
url: file.getURL(domain),
|
||||
}),
|
||||
text: () => res.send(file.getURL()),
|
||||
text: () => res.send(file.getURL(domain)),
|
||||
html: () => {
|
||||
req.flash('success', 'Upload success!');
|
||||
req.flash('url', file.getURL(domain));
|
||||
res.redirectBack('/');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected async deleteFile(req: Request, res: Response): Promise<void> {
|
||||
public static async deleteFile(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const slug = req.params.slug;
|
||||
if (!slug) throw new BadRequestError('Cannot delete nothing.', 'Please provide a slug.', req.url);
|
||||
|
||||
const file = await FileModel.getBySlug(req.params.slug);
|
||||
if (!file) throw new NotFoundHttpError('File', req.url);
|
||||
if (!file) return next();
|
||||
if (!file.canDelete(req.models.user!.id!)) throw new ForbiddenHttpError('file', req.url);
|
||||
|
||||
switch (file.storage_type) {
|
||||
@ -164,22 +115,10 @@ 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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const FILE_UPLOAD_FORM_MIDDLEWARE = FILE_UPLOAD_MIDDLEWARE(() => {
|
||||
export const FILE_UPLOAD_FORM_MIDDLEWARE = FILE_UPLOAD_MIDDLEWARE(() => {
|
||||
const form = new IncomingForm();
|
||||
form.uploadDir = 'storage/tmp';
|
||||
form.maxFileSize = config.get<number>('max_upload_size') * 1024 * 1024;
|
||||
|
87
src/controllers/LinkController.ts
Normal file
87
src/controllers/LinkController.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import Controller from "wms-core/Controller";
|
||||
import {NextFunction, Request, Response} from "express";
|
||||
import {BadRequestError, NotFoundHttpError, ServerError} from "wms-core/HttpError";
|
||||
import config from "config";
|
||||
import {REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
|
||||
import URLRedirect from "../models/URLRedirect";
|
||||
import URLRedirectController from "./URLRedirectController";
|
||||
import FileModel from "../models/FileModel";
|
||||
import fs from "fs";
|
||||
import Logger from "wms-core/Logger";
|
||||
import generateSlug from "../SlugGenerator";
|
||||
import FileController, {FILE_UPLOAD_FORM_MIDDLEWARE} from "./FileController";
|
||||
|
||||
export default class LinkController extends Controller {
|
||||
routes(): void {
|
||||
this.post('/', this.postFile, 'post-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
|
||||
this.delete('/:slug', FileController.deleteFile, 'delete-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
|
||||
this.get('/:slug', this.downloadFile, 'get-file');
|
||||
this.put('/:slug', this.putFile, 'put-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
|
||||
|
||||
this.post('/', URLRedirectController.addURL, 'post-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
|
||||
this.delete('/:slug', this.deleteURL, 'delete-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
|
||||
this.get('/:slug', this.getURLRedirect, 'get-url');
|
||||
this.put('/:slug', URLRedirectController.addURL, 'put-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
|
||||
|
||||
this.get(/(.*)/, this.domainFilter);
|
||||
}
|
||||
|
||||
protected async downloadFile(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const file = await FileModel.getBySlug(req.params.slug);
|
||||
if (!file) return next();
|
||||
if (file.shouldBeDeleted()) {
|
||||
await fs.unlinkSync(file.storage_path);
|
||||
await file.delete();
|
||||
Logger.info('Deleted', file.storage_path, `(${file.real_name})`);
|
||||
return next();
|
||||
}
|
||||
|
||||
switch (file.storage_type) {
|
||||
case 'local':
|
||||
res.download(file.storage_path, file.real_name);
|
||||
break;
|
||||
default:
|
||||
throw new ServerError(`This file cannot be served. Download protocol for ${file.storage_type} storage type not implemented.`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async postFile(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
if (req.body.type !== 'file') return next();
|
||||
|
||||
await FileController.handleFileUpload(req.body.slug || await generateSlug(10), req, res);
|
||||
}
|
||||
|
||||
protected async putFile(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
if (req.body.type !== 'file') return next();
|
||||
const slug = req.params.slug;
|
||||
if (!slug) throw new BadRequestError('Cannot put without a slug.', 'Either provide a slug or use POST method instead.', req.url);
|
||||
|
||||
await FileController.handleFileUpload(slug, req, res);
|
||||
}
|
||||
|
||||
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 domainFilter(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
if (req.hostname !== config.get('domain')) {
|
||||
if (req.path === '/') return res.redirect(config.get<string>('base_url'));
|
||||
throw new NotFoundHttpError('Page', req.url);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
64
src/controllers/URLRedirectController.ts
Normal file
64
src/controllers/URLRedirectController.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import Controller from "wms-core/Controller";
|
||||
import {NextFunction, Request, Response} from "express";
|
||||
import URLRedirect from "../models/URLRedirect";
|
||||
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
|
||||
import generateSlug from "../SlugGenerator";
|
||||
import config from "config";
|
||||
|
||||
export default class URLRedirectController extends Controller {
|
||||
routes(): void {
|
||||
this.get('/url/shrink', this.getURLShrinker, 'url-shrinker', REQUIRE_AUTH_MIDDLEWARE);
|
||||
this.get('/url/shrink/script', this.downloadLinuxScript, 'url-linux-script');
|
||||
this.post('/url/shrink', this.addURLFrontend, 'shrink-url', REQUIRE_AUTH_MIDDLEWARE);
|
||||
this.get('/urls/:page([0-9]+)?', this.getURLRedirectManager, 'url-manager', REQUIRE_AUTH_MIDDLEWARE);
|
||||
}
|
||||
|
||||
protected async getURLShrinker(req: Request, res: Response): Promise<void> {
|
||||
const allowedDomains = config.get<string[]>('allowed_url_domains');
|
||||
res.render('url-shrinker', {
|
||||
allowed_domains: allowedDomains,
|
||||
default_domain: allowedDomains[config.get<number>('default_url_domain_for_urls')],
|
||||
});
|
||||
}
|
||||
|
||||
protected async downloadLinuxScript(req: Request, res: Response): Promise<void> {
|
||||
res.download('assets/files/shrink_url.sh', 'shrink_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 addURLFrontend(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
req.body.type = 'url';
|
||||
await URLRedirectController.addURL(req, res, next, req.body.autogen_url === undefined && req.body.slug ? req.body.slug : await generateSlug(10));
|
||||
}
|
||||
|
||||
public static 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();
|
||||
|
||||
const domain = req.body.url_domain || config.get<string[]>('allowed_url_domains')[config.get<number>('default_url_domain_for_urls')];
|
||||
res.format({
|
||||
json: () => res.json({
|
||||
url: urlRedirect.getURL(domain),
|
||||
}),
|
||||
text: () => res.send(urlRedirect.getURL(domain)),
|
||||
html: () => {
|
||||
req.flash('success', 'URL shrunk successfully!');
|
||||
req.flash('url', urlRedirect.getURL(domain));
|
||||
res.redirectBack('/');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
20
src/migrations/CreateUrlRedirectsTable.ts
Normal file
20
src/migrations/CreateUrlRedirectsTable.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
@ -4,6 +4,7 @@ import Controller from "wms-core/Controller";
|
||||
import config from "config";
|
||||
import User from "wms-core/auth/models/User";
|
||||
import {Request} from "express";
|
||||
import URLRedirect from "./URLRedirect";
|
||||
|
||||
export default class FileModel extends Model {
|
||||
public static get table(): string {
|
||||
@ -15,7 +16,7 @@ export default class FileModel extends Model {
|
||||
}
|
||||
|
||||
public static async paginateForUser(req: Request, perPage: number, user_id: number): Promise<FileModel[]> {
|
||||
return await this.paginate<FileModel>(req, perPage, this.select().where('user_id', user_id));
|
||||
return await this.paginate(req, perPage, this.select().where('user_id', user_id));
|
||||
}
|
||||
|
||||
public readonly user_id!: number;
|
||||
@ -29,7 +30,7 @@ export default class FileModel extends Model {
|
||||
|
||||
protected init() {
|
||||
this.addProperty('user_id', new Validator().defined().exists(User, 'id'));
|
||||
this.addProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(this, 'slug'));
|
||||
this.addProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(FileModel, 'slug').unique(URLRedirect, 'slug'));
|
||||
this.addProperty('real_name', new Validator().defined().minLength(1).maxLength(259));
|
||||
this.addProperty('storage_type', new Validator().defined().maxLength(64));
|
||||
this.addProperty('storage_path', new Validator().defined().maxLength(1745));
|
||||
@ -38,8 +39,8 @@ export default class FileModel extends Model {
|
||||
this.addProperty('ttl', new Validator().defined().min(0).max(4294967295));
|
||||
}
|
||||
|
||||
public getURL(): string {
|
||||
return config.get<string>('base_url') + Controller.route('get-file', {
|
||||
public getURL(domain: string = config.get<string>('base_url')): string {
|
||||
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-file', {
|
||||
slug: this.slug,
|
||||
});
|
||||
}
|
||||
|
44
src/models/URLRedirect.ts
Normal file
44
src/models/URLRedirect.ts
Normal 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(domain: string = config.get<string>('base_url')): string {
|
||||
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-url', {
|
||||
slug: this.slug,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
159
views/desktop-utility.njk
Normal file
159
views/desktop-utility.njk
Normal file
@ -0,0 +1,159 @@
|
||||
<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...) and curl, you can
|
||||
download and use these scripts:</p>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Download link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>upload_file.sh</td>
|
||||
<td><a href="{{ route('file-linux-script') }}">Download</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>shrink_url.sh</td>
|
||||
<td><a href="{{ route('url-linux-script') }}">Download</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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 (<code>$HOME/.ily_token</code>).</p>
|
||||
<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>
|
||||
</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>Required?</th>
|
||||
<th>Value(s)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<th colspan="4">File upload</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>type</td>
|
||||
<td>Request type</td>
|
||||
<td>Yes</td>
|
||||
<td>file</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>upload</td>
|
||||
<td>File field</td>
|
||||
<td>Yes</td>
|
||||
<td>The actual file contents</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ttl</td>
|
||||
<td>How much time (in seconds) to keep the file</td>
|
||||
<td>No</td>
|
||||
<td>0 (never delete), 30 (delete after 30s)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>url_domain</td>
|
||||
<td>Choose domain name</td>
|
||||
<td>No</td>
|
||||
<td>{{ allowed_domains.join('|') }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th colspan="4">URL shrink</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>type</td>
|
||||
<td>Request type</td>
|
||||
<td>Yes</td>
|
||||
<td>url</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>target_url</td>
|
||||
<td>Target url</td>
|
||||
<td>Yes</td>
|
||||
<td>A valid URL starting with https:// or http://</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>url_domain</td>
|
||||
<td>Choose domain name</td>
|
||||
<td>No</td>
|
||||
<td>{{ allowed_domains.join('|') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>For examples with curl, please download and review the scripts above.</p>
|
||||
</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>
|
@ -39,7 +39,9 @@
|
||||
{% set expires_at = file.getExpirationDate() %}
|
||||
<td>{% if expires_at %}{{ expires_at.toISOString() }}{% else %}Never{% endif %}</td>
|
||||
<td>
|
||||
<a href="{{ route('delete-file-frontend', file.slug) }}" class="button danger"><i data-feather="trash"></i> Delete</a>
|
||||
<form action="{{ route('delete-file-frontend', file.slug) }}" method="post">
|
||||
<button class="button danger"><i data-feather="trash"></i> Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@ -21,7 +21,7 @@
|
||||
{{ macros.field(_locals, 'number', 'expire_after_days', '30', 'How many days to delete this file after', null, validation_attributes='max="1825"') }}
|
||||
{{ macros.field(_locals, 'checkbox', 'never_expire', '', 'Never delete this file') }}
|
||||
|
||||
{{ macros.field(_locals, 'text', 'slug', '', 'Custom url slug', 'Example: beautiful_image.jpg sets url to https://ily.li/beautiful_image.jpg', validation_attributes='disabled') }}
|
||||
{{ macros.field(_locals, 'text', 'slug', '', 'Custom url slug', 'Example: beautiful_image.jpg sets url to https://'+default_domain+'/beautiful_image.jpg', validation_attributes='disabled') }}
|
||||
{{ macros.field(_locals, 'checkbox', 'autogen_url', '', 'Generate url automatically', null, validation_attributes='checked') }}
|
||||
|
||||
{{ macros.csrf(getCSRFToken) }}
|
||||
@ -40,118 +40,17 @@
|
||||
<div class="content"></div>
|
||||
<button class="copy-button"><i data-feather="copy"></i></button>
|
||||
</div>
|
||||
</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 '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 '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 '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>
|
||||
{% set url = flash('url') %}
|
||||
{% if url | length %}
|
||||
<div class="copyable-text">
|
||||
<div class="content">{{ token.secret }}</div>
|
||||
<div class="title">URL</div>
|
||||
<div class="content">{{ url }}</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>
|
||||
<a href="{{ route('revoke-token', token.id) }}" class="button danger"><i data-feather="trash"></i> Revoke</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% include 'desktop-utility.njk' %}
|
||||
{% endblock %}
|
@ -8,6 +8,8 @@
|
||||
<link rel="shortcut icon" type="image/png" href="/img/logox128.png">
|
||||
<link rel="shortcut icon" type="image/svg" href="/img/logo.svg">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
{% if description %}
|
||||
<meta name="description" content="{{ description }}">
|
||||
{% endif %}
|
||||
|
@ -14,17 +14,20 @@
|
||||
{% block header %}
|
||||
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> {{ app.name }}</a>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ route('about') }}"><i data-feather="info"></i> About</a></li>
|
||||
<button id="menu-button"><i data-feather="menu"></i></button>
|
||||
<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-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> <span class="tip">File manager</span></a></li>
|
||||
<li><a href="{{ route('file-upload') }}"><i data-feather="upload"></i> <span class="tip">Upload file</span></a></li>
|
||||
<li><a href="{{ route('url-manager') }}"><i data-feather="link"></i> <span class="tip">URL manager</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> Backend</a></li>
|
||||
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ route('logout') }}"><i data-feather="log-out"></i> Logout</a></li>
|
||||
<li><a href="{{ route('logout') }}"><i data-feather="log-out"></i> <span class="tip">Logout</span></a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ route('auth') }}"><i data-feather="user"></i> Login / Register</a></li>
|
||||
<li><a href="{{ route('auth') }}"><i data-feather="user"></i> <span class="tip">Login / Register</span></a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
38
views/url-manager.njk
Normal file
38
views/url-manager.njk
Normal file
@ -0,0 +1,38 @@
|
||||
{% 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>
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
40
views/url-shrinker.njk
Normal file
40
views/url-shrinker.njk
Normal file
@ -0,0 +1,40 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = app.name + ' - URL shrinker' %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/url-shrinker.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Shrink URLs</h1>
|
||||
<p>(no phishing allowed)</p>
|
||||
|
||||
<div class="container">
|
||||
<section class="panel">
|
||||
<h2>Shrink a URL</h2>
|
||||
|
||||
<form action="{{ route('shrink-url') }}" method="POST" id="url-shrink-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://'+default_domain+'/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> Shrink 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 %}
|
Loading…
Reference in New Issue
Block a user