Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2020-07-06 16:01:26 +02:00
commit 5016b46629
28 changed files with 1351 additions and 805 deletions

View File

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

View File

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

View File

@ -25,3 +25,6 @@ $warningColor: desaturate($warningText, 50%);
$error: #ff0000;
$errorText: darken($error, 30%);
$errorColor: desaturate($errorText, 50%);
// Responsivity
$menuLayoutSwitchTreshold: 700px;

View File

@ -51,32 +51,132 @@ header {
}
}
nav ul {
display: flex;
flex-direction: row;
margin: 0;
padding: 0;
nav {
ul {
display: flex;
flex-direction: row;
margin: 0;
padding: 0;
font-size: 20px;
font-size: 20px;
li {
list-style: none;
li {
list-style: none;
a, span {
display: flex;
flex-direction: row;
align-items: center;
height: 64px;
padding: 0 24px;
a, span {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
height: 64px;
padding: 0 24px;
.feather {
--icon-size: 24px;
}
&:hover {
.tip {
visibility: visible;
opacity: 1;
transition: opacity ease-out 100ms;
transition-delay: 150ms;
}
}
}
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: 24px;
margin-right: 10px;
--icon-size: 28px;
margin: 0 8px;
}
}
a:hover {
background-color: rgba(255, 255, 255, 0.07);
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;
}
}
}

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

@ -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>
{% 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>
<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>
<a href="{{ route('revoke-token', token.id) }}" class="button danger"><i data-feather="trash"></i> Revoke</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% include 'desktop-utility.njk' %}
{% endblock %}

View File

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

View File

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

1163
yarn.lock

File diff suppressed because it is too large Load Diff