Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2020-06-15 12:14:39 +02:00
commit 92c258805d
47 changed files with 11519 additions and 93 deletions

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ node_modules
public
dist
yarn-error.log
storage/tmp
storage/uploads

View File

@ -0,0 +1 @@
# ily.li is a simple file self-hosting solution

16
app.service Normal file
View File

@ -0,0 +1,16 @@
[Unit]
Description=ily.li website
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ilyli
Group=ilyli
WorkingDirectory=/home/ilyli/live
Restart=on-success
Environment=NODE_ENV=production
ExecStart=/bin/node .
[Install]
WantedBy=multi-user.target

View File

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

View File

@ -7,65 +7,82 @@
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg6"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
sodipodi:docname="logo.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
inkscape:export-filename="/r/arisu/dev/streams/wms/assets/img/logox1024.png"
inkscape:export-xdpi="4096"
inkscape:export-ydpi="4096">
id="svg12"
version="1.1"
class="feather feather-share-2"
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
height="24"
width="24">
<metadata
id="metadata12">
id="metadata18">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
id="defs16" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="2560"
inkscape:window-height="1381"
id="namedview8"
showgrid="false"
inkscape:zoom="45.254834"
inkscape:cx="12.300687"
inkscape:cy="13.723964"
inkscape:window-x="1920"
inkscape:window-y="32"
inkscape:current-layer="svg12"
inkscape:window-maximized="1"
inkscape:current-layer="svg6">
<inkscape:grid
spacingx="0.5"
spacingy="0.5"
type="xygrid"
id="grid4542" />
</sodipodi:namedview>
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#00c3b2;fill-opacity:1;fill-rule:nonzero;stroke:#00c3b2;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 4.6020263,4.6020261 c -4.08077722,4.0820101 -4.08077722,10.7139369 0,14.7959479 m 0,0 0.6656889,-0.665689 c -3.7210992,-3.722224 -3.7210992,-9.7423466 0,-13.4645699 L 4.6020263,4.6020261 m 14.7959477,0 -0.665689,0.665689 c 3.721099,3.7222233 3.721099,9.7423459 0,13.4645699 l 0.665689,0.665689 m 0,0 c 4.080777,-4.082011 4.080777,-10.7139378 0,-14.7959479 M 8.0959735,15.904026 c -2.1644289,-2.162022 -2.1644289,-5.655225 0,-7.8172472 m 0,0 L 7.4302847,7.419251 c -2.5246945,2.5218873 -2.5246945,6.628577 0,9.150465 l 0.6656888,-0.66569 M 15.904026,8.0959736 c 2.164429,2.1620214 2.164429,5.6552244 0,7.8172474 m 0,0 0.66569,0.667527 c 2.524693,-2.521887 2.524693,-6.6285761 0,-9.1504633 l -0.66569,0.6656889"
id="path4"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccc" />
inkscape:window-y="32"
inkscape:window-x="1920"
inkscape:cy="12"
inkscape:cx="13.21659"
inkscape:zoom="36.166667"
showgrid="false"
id="namedview14"
inkscape:window-height="1381"
inkscape:window-width="2560"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
guidetolerance="10"
gridtolerance="10"
objecttolerance="10"
borderopacity="1"
bordercolor="#666666"
pagecolor="#ffffff" />
<circle
r="2.7272723"
style="stroke:#842cff;stroke-width:1.81818;stroke-opacity:1"
id="circle2"
cy="5.6363649"
cx="17.454544" />
<circle
r="2.7272723"
style="stroke:#842cff;stroke-width:1.81818;stroke-opacity:1"
id="circle4"
cy="12"
cx="6.5454555" />
<circle
r="2.7272723"
style="stroke:#842cff;stroke-width:1.81818;stroke-opacity:1"
id="circle6"
cy="18.363636"
cx="17.454544" />
<line
style="stroke:#842cff;stroke-width:1.81818;stroke-opacity:1"
id="line8"
y2="16.990906"
x2="15.109089"
y1="13.372726"
x1="8.8999996" />
<line
style="stroke:#842cff;stroke-width:1.81818;stroke-opacity:1"
id="line10"
y2="10.627273"
x2="8.8999996"
y1="7.0090923"
x1="15.099998" />
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,6 +1,7 @@
import './external_links';
import './message_icons';
import './forms';
import './copyable_text';
import '../sass/app.scss';

View File

@ -0,0 +1,12 @@
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.copyable-text').forEach(el => {
const contentEl = el.querySelector('.content');
contentEl.addEventListener('click', () => {
window.getSelection().selectAllChildren(contentEl);
});
el.querySelector('.copy-button').addEventListener('click', () => {
window.getSelection().selectAllChildren(contentEl);
document.execCommand('copy');
});
});
});

132
assets/js/fm.js Normal file
View File

@ -0,0 +1,132 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('upload-form');
if (!form) return;
const upload = document.getElementById('file-upload');
const uploadLink = document.getElementById('file-upload-link');
const uploadField = document.getElementById('field-upload');
const neverExpireCheckbox = document.getElementById('field-never_expire');
const expireAfterDaysField = document.getElementById('field-expire_after_days');
const autogenUrlCheckbox = document.getElementById('field-autogen_url');
const slugField = document.getElementById('field-slug');
neverExpireCheckbox.addEventListener('change', () => {
expireAfterDaysField.disabled = neverExpireCheckbox.checked;
});
autogenUrlCheckbox.addEventListener('change', () => {
slugField.disabled = autogenUrlCheckbox.checked;
});
let uploadForm;
form.addEventListener('submit', e => {
e.preventDefault();
if (!uploadForm || uploadForm.isFinished()) {
uploadForm = new UploadForm(form, upload, uploadLink, uploadField.files[0].name);
uploadForm.updateView();
uploadForm.start();
}
});
});
function UploadForm(form, upload, uploadLink, fileName) {
this.form = form;
this.upload = upload;
this.uploadLink = uploadLink;
this.fileName = fileName;
this.progressBar = this.upload.querySelector('.progress-bar');
this.progressBarContent = this.progressBar.querySelector('.content');
this.status = this.upload.querySelector('.status');
this.speed = this.status.querySelector('.speed');
this.finished = false;
this.xferSpeed = [];
this.lastTransferTime = null;
this.xhr = new XMLHttpRequest();
this.xhr.responseType = 'json';
this.xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
this.progressBar.classList.remove('undefined');
let percent = ((e.loaded / e.total) * 100).toFixed(2) + '%';
this.progressBar.style = `--progress: ${percent}`;
this.progressBarContent.innerText = percent;
this.updateSpeed(e.loaded);
} else {
this.progressBar.classList.add('undefined');
}
});
this.xhr.upload.addEventListener('loadstart', () => {
this.status.classList.remove('hidden');
});
this.xhr.addEventListener('load', () => {
this.finished = true;
let response = this.xhr.response;
console.log('done', response);
if (response.status === 'error') {
if (response.messages) {
this.restoreView();
window.applyFormMessages(this.form, response.messages);
}
} else if (response.url) {
this.status.innerHTML = 'Done!';
this.uploadLink.querySelector('.content').innerText = response.url;
this.uploadLink.classList.remove('hidden');
} else {
window.location.reload();
}
});
this.xhr.addEventListener('error', (e) => {
this.finished = true;
console.error('error', e);
this.status.innerHTML = 'Error; upload was interrupted.';
});
}
UploadForm.prototype.isFinished = function () {
return this.finished;
}
UploadForm.prototype.updateView = function () {
this.upload.querySelector('.name').innerText = this.fileName;
this.form.classList.add('hidden');
this.upload.classList.remove('hidden');
this.status.innerHTML = `Uploading @ <span class="speed">--</span>`;
this.speed = this.status.querySelector('.speed');
}
UploadForm.prototype.restoreView = function () {
this.status.classList.add('hidden');
this.upload.classList.add('hidden');
this.form.classList.remove('hidden');
};
UploadForm.prototype.start = function () {
const formData = new FormData(this.form);
this.xhr.open('POST', this.form.action);
this.xhr.send(formData);
}
const units = ['K', 'M', 'G', 'T'];
UploadForm.prototype.updateSpeed = function (loaded) {
console.log(this.xferSpeed);
const time = new Date().getTime();
if (this.lastTransferTime) {
this.xferSpeed.push((loaded - this.lastLoaded) / (time - this.lastTransferTime));
if (this.xferSpeed.length > 10) this.xferSpeed = this.xferSpeed.slice(1);
let speed = this.xferSpeed.reduce((v, c) => v + c) / this.xferSpeed.length;
let unit = 0;
while (speed >= 1000 && unit < units.length - 1) {
speed /= 1000;
unit++;
}
this.speed.innerText = (speed).toFixed(2) + units[unit] + 'Bps';
}
this.lastTransferTime = time;
this.lastLoaded = loaded;
}

View File

@ -8,4 +8,22 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
});
});
});
window.applyFormMessages = function (formElement, messages) {
for (const fieldName of Object.keys(messages)) {
const field = formElement.querySelector('#field-' + fieldName);
let parent = field.parentElement;
while (parent && !parent.classList.contains('form-field')) parent = parent.parentElement;
if (field) {
let err = field.querySelector('.error');
if (!err) {
err = document.createElement('div');
err.classList.add('error');
parent.insertBefore(err, parent.querySelector('.hint') || parent);
}
err.innerHTML = `<i data-feather="x-circle"></i> ${messages[fieldName].message}`;
}
}
}

View File

@ -1,6 +1,6 @@
$primary: darken(#242b33, 2%);
$primaryForeground: #f0f0f0;
$secondary: lighten(#00766c, 10%);
$secondary: lighten(#842cff, 10%);
$secondaryForeground: $primaryForeground;
$backgroundColor: darken($primary, 4%);

View File

@ -1 +1,2 @@
@import "layout";
@import "layout";
@import "fm";

11
assets/sass/fm.scss Normal file
View File

@ -0,0 +1,11 @@
@import "vars";
#file-upload {
padding: 8px;
background: $infoColor;
border-radius: 5px;
.name, .status {
text-align: center;
}
}

View File

@ -194,7 +194,7 @@ form {
border-radius: 5px;
font-size: 16px;
&:focus, &:not([value=""]) {
&:focus, &:not([value=""]), &[type="file"] {
~ label {
top: 8px;
font-size: 14px;
@ -540,4 +540,67 @@ button, .button {
margin: 8px;
}
}
}
}
.copyable-text {
display: flex;
flex-direction: row;
margin: 8px;
background-color: darken($backgroundColor, 2%);
border-radius: 5px;
overflow: hidden;
.title {
padding: 8px;
}
.content {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
padding: 8px;
}
.copy-button {
margin: 0;
padding: 0;
border-radius: 0;
.feather {
--icon-size: 20px;
margin: 8px;
}
}
}
.hidden {
display: none;
}
.progress-bar {
position: relative;
display: block;
margin: 8px;
padding: 4px;
background: #fff1;
border-radius: 5px;
overflow: hidden;
text-align: center;
.content {
position: relative;
}
&::before {
content: "";
display: block;
position: absolute;
left: 0;
top: 0;
width: var(--progress);
height: 100%;
background: $secondary;
}
}

View File

@ -1,2 +1,21 @@
export default Object.assign(require("wms-core/config/default").default, {
domain: 'localhost',
base_url: 'http://localhost:4893',
public_websocket_url: 'ws://localhost:4893',
port: 4893,
mysql: {
connectionLimit: 10,
host: "localhost",
user: "root",
password: "",
database: "ilyli",
create_database_automatically: false
},
magic_link: {
validity_period: 20
},
gitlab_webhook_token: 'secret',
newlyGeneratedSlugSize: 3,
default_file_ttl: 30, // 30 seconds
max_upload_size: 1, // MB
});

View File

@ -1,2 +1,19 @@
export default Object.assign(require("wms-core/config/production").default, {
domain: 'ily.li',
base_url: 'https://ily.li',
public_websocket_url: 'wss://ily.li',
mysql: {
connectionLimit: 10,
host: "localhost",
user: "root",
password: "",
database: "ilyli",
},
magic_link: {
validity_period: 900
},
gitlab_webhook_token: 'CHANGEME',
newlyGeneratedSlugSize: 5,
default_file_ttl: 30 * 24 * 3600, // 30 days
max_upload_size: 8192, // MB
});

View File

@ -1,2 +1,9 @@
export default Object.assign(require("wms-core/config/test").default, {
mysql: {
database: "ilyli_test",
create_database_automatically: true
},
magic_link: {
validity_period: 2
},
});

View File

@ -1,8 +1,8 @@
{
"name": "aldap",
"version": "0.1.0",
"description": "Authentication LDAP server",
"repository": "git@gitlab.com:ArisuOngaku/aldap.git",
"name": "ily.li",
"version": "0.2.0",
"description": "Self-hosted file pusher",
"repository": "git@gitlab.com:ArisuOngaku/ily.li.git",
"author": "Alice Gaudon <alice@gaudon.pro>",
"private": true,
"main": "dist/main.js",
@ -10,7 +10,7 @@
"test": "jest --verbose --runInBand",
"dist-webpack": "webpack --mode production",
"dist": "tsc && npm run dist-webpack",
"dev": "concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon dist/main.js\" \"webpack --watch --mode development\" \"maildev\"",
"dev": "concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"webpack --watch --mode development\" \"maildev\"",
"start": "yarn dist && node dist/main.js"
},
"devDependencies": {
@ -18,6 +18,7 @@
"@babel/preset-env": "^7.9.5",
"@types/config": "^0.0.36",
"@types/express": "^4.17.6",
"@types/formidable": "^1.0.31",
"@types/jest": "^25.2.1",
"@types/node": "^13.13.2",
"babel-loader": "^8.1.0",
@ -45,6 +46,7 @@
},
"dependencies": {
"config": "^3.3.1",
"express": "^4.17.1"
"express": "^4.17.1",
"formidable": "^1.2.2"
}
}

View File

@ -16,11 +16,28 @@ import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
import FormHelperComponent from "wms-core/components/FormHelperComponent";
import CsrfProtectionComponent from "wms-core/components/CsrfProtectionComponent";
import WebSocketServerComponent from "wms-core/components/WebSocketServerComponent";
import HomeController from "./controllers/HomeController";
import AboutController from "./controllers/AboutController";
import AutoUpdateComponent from "wms-core/components/AutoUpdateComponent";
import AuthController from "./controllers/AuthController";
import MagicLinkWebSocketListener from "wms-core/auth/magic_link/MagicLinkWebSocketListener";
import MagicLinkController from "./controllers/MagicLinkController";
import MailController from "wms-core/auth/MailController";
import FileController from "./controllers/FileController";
import CreateUsersAndUserEmailsTable from "wms-core/auth/migrations/CreateUsersAndUserEmailsTable";
import CreateMagicLinksTable from "wms-core/auth/migrations/CreateMagicLinksTable";
import CreateAuthTokensTable from "./migrations/CreateAuthTokensTable";
import AuthComponent from "wms-core/auth/AuthComponent";
import AuthGuard from "wms-core/auth/AuthGuard";
import MagicLink from "wms-core/auth/models/MagicLink";
import AuthToken from "./models/AuthToken";
import {MagicLinkActionType} from "./controllers/MagicLinkActionType";
import {Request} from "express";
import CreateFilesTable from "./migrations/CreateFilesTable";
import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField";
export default class ExampleApp extends Application {
export default class App extends Application {
private readonly port: number;
private magicLinkWebSocketListener?: MagicLinkWebSocketListener;
constructor(port: number) {
super(require('../package.json').version);
@ -31,6 +48,11 @@ export default class ExampleApp extends Application {
return [
CreateMigrationsTable,
CreateLogsTable,
CreateUsersAndUserEmailsTable,
CreateMagicLinksTable,
CreateAuthTokensTable,
CreateFilesTable,
IncreaseFilesSizeField,
];
}
@ -66,6 +88,25 @@ export default class ExampleApp extends Application {
// Session
this.use(redisComponent);
this.use(new SessionComponent(redisComponent));
this.use(new AuthComponent(new class extends AuthGuard<MagicLink | AuthToken> {
public async getProofForSession(session: Express.Session): Promise<any | null> {
return await MagicLink.bySessionID(session.id, [MagicLinkActionType.LOGIN, MagicLinkActionType.REGISTER]);
}
public async getProofForRequest(req: Request): Promise<MagicLink | AuthToken | null> {
const authorization = req.header('Authorization');
if (authorization) {
const token = await AuthToken.getBySecret(authorization);
if (token) {
token.use();
await token.save();
}
return token;
}
return super.getProofForRequest(req);
}
}));
// Utils
this.use(new RedirectBackComponent());
@ -79,9 +120,22 @@ export default class ExampleApp extends Application {
}
private registerWebSocketListeners() {
this.magicLinkWebSocketListener = new MagicLinkWebSocketListener();
this.use(this.magicLinkWebSocketListener);
}
private registerControllers() {
this.use(new HomeController());
// Priority
this.use(new AuthController());
this.use(new MagicLinkController(this.magicLinkWebSocketListener!));
// Core functionality
this.use(new MailController());
// Semi-static
this.use(new AboutController());
// Global slug
this.use(new FileController());
}
}

6
src/MagicLinkMail.ts Normal file
View File

@ -0,0 +1,6 @@
import {MailTemplate} from "wms-core/Mail";
export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link',
data => data.type === 'register' ? 'Registration' : 'Login magic link'
);

View File

@ -0,0 +1,13 @@
import Controller from "wms-core/Controller";
import {Request, Response} from "express";
export default class AboutController extends Controller {
routes(): void {
this.get('/', this.getAbout, 'home');
this.get('/', this.getAbout, 'about');
}
private async getAbout(req: Request, res: Response) {
res.render('about');
}
}

View File

@ -0,0 +1,8 @@
import MagicLinkAuthController from "wms-core/auth/magic_link/MagicLinkAuthController";
import {MAGIC_LINK_MAIL} from "../MagicLinkMail";
export default class AuthController extends MagicLinkAuthController {
public constructor() {
super(MAGIC_LINK_MAIL);
}
}

View File

@ -0,0 +1,182 @@
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 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";
const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export default class FileController extends Controller {
routes(): void {
this.get('/files/upload', this.getFileUploader, 'file-upload', REQUIRE_AUTH_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/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);
}
protected async getFileUploader(req: Request, res: Response): Promise<void> {
res.render('file-upload', {
max_upload_size: config.get<string>('max_upload_size'),
auth_tokens: await AuthToken.getForUser(req.models.user!.id!),
});
}
protected async getFileManager(req: Request, res: Response): Promise<void> {
res.render('file-manager', {
files: await FileModel.paginateForUser(req, 100, req.models.user!.id!),
});
}
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>(`${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);
}
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> {
// 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);
}
let upload = req.files['upload'];
// TTL
let ttl = config.get<number>('default_file_ttl');
if (req.body.never_expire !== undefined) ttl = 0;
if (req.body.ttl !== undefined) ttl = parseInt(req.body.ttl);
else if (req.body.expire_after_days !== undefined) ttl = parseInt(req.body.expire_after_days) * 24 * 3600;
const file = new FileModel({
user_id: req.models.user!.id,
slug: slug,
real_name: upload.name,
storage_type: 'local',
storage_path: 'storage/uploads/' + slug,
size: upload.size,
ttl: ttl,
});
await file.save();
fs.renameSync(upload.path, file.storage_path);
res.format({
json: () => res.json({
url: file.getURL(),
}),
text: () => res.send(file.getURL()),
html: () => {
req.flash('success', 'Upload success!');
res.redirectBack('/');
},
});
}
protected async deleteFile(req: Request, res: Response): 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.canDelete(req.models.user!.id!)) throw new ForbiddenHttpError('file', req.url);
switch (file.storage_type) {
case 'local':
await file.delete();
fs.unlinkSync(file.storage_path);
break;
default:
throw new ServerError(`This file cannot be deleted. Deletion protocol for ${file.storage_type} storage type not implemented.`);
}
res.format({
json: () => res.json({
status: 'success',
}),
text: () => res.send('success'),
html: () => {
req.flash('success', 'Successfully deleted file.');
res.redirectBack('/');
},
});
}
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(() => {
const form = new IncomingForm();
form.uploadDir = 'storage/tmp';
form.maxFileSize = config.get<number>('max_upload_size') * 1024 * 1024;
return form;
}, 'upload');

View File

@ -1,17 +0,0 @@
import Controller from "wms-core/Controller";
import {Request, Response} from "express";
export default class HomeController extends Controller {
routes(): void {
this.get('/', this.getHome, 'home');
this.get('/about', this.getAbout, 'about');
}
private async getHome(req: Request, res: Response) {
res.render('home');
}
private async getAbout(req: Request, res: Response) {
res.render('about');
}
}

View File

@ -0,0 +1,17 @@
import MagicLink from "wms-core/auth/models/MagicLink";
export enum MagicLinkActionType {
LOGIN = 'Login',
REGISTER = 'Register',
}
export function getActionMessage(magicLink: MagicLink): string {
switch (magicLink.getActionType()) {
case MagicLinkActionType.LOGIN:
return 'You have been authenticated.';
case MagicLinkActionType.REGISTER:
return 'You have been registered.';
}
return '';
}

View File

@ -0,0 +1,27 @@
import _MagicLinkController from "wms-core/auth/magic_link/MagicLinkController";
import {Request, Response} from "express";
import Controller from "wms-core/Controller";
import MagicLinkWebSocketListener from "wms-core/auth/magic_link/MagicLinkWebSocketListener";
import MagicLink from "wms-core/auth/models/MagicLink";
import AuthController from "./AuthController";
import {MagicLinkActionType} from "./MagicLinkActionType";
export default class MagicLinkController extends _MagicLinkController {
constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener) {
super(magicLinkWebSocketListener);
}
protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void> {
switch (magicLink.getActionType()) {
case MagicLinkActionType.LOGIN:
case MagicLinkActionType.REGISTER:
await AuthController.checkAndAuth(req, magicLink);
// Auth success
const user = await req.authGuard.getUserForSession(req.session!);
req.flash('success', `Authentication success. Welcome, ${user?.name}!`);
res.redirect(Controller.route('home'));
break;
}
}
}

View File

@ -1,8 +1,9 @@
import Logger from "wms-core/Logger";
import ExampleApp from "./ExampleApp";
import App from "./App";
import config from "config";
(async () => {
const app = new ExampleApp(4899);
const app = new App(config.get<number>('port'));
await app.start();
})().catch(err => {
Logger.error(err);

View File

@ -0,0 +1,21 @@
import {Connection} from "mysql";
import Migration from "wms-core/db/Migration";
export default class CreateAuthTokensTable extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query('CREATE TABLE auth_tokens(' +
'id INT NOT NULL AUTO_INCREMENT,' +
'user_id INT NOT NULL,' +
'secret VARCHAR(64) UNIQUE NOT NULL,' +
'created_at DATETIME NOT NULL DEFAULT NOW(),' +
'used_at DATETIME NOT NULL DEFAULT NOW(),' +
'ttl INT UNSIGNED NOT NULL,' +
'PRIMARY KEY (id)' +
')', connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE IF EXISTS auth_tokens', connection);
}
}

View File

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

View File

@ -0,0 +1,12 @@
import Migration from "wms-core/db/Migration";
import {Connection} from "mysql";
export default class IncreaseFilesSizeField extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query('ALTER TABLE files MODIFY size BIGINT UNSIGNED', connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('ALTER TABLE files MODIFY size INT UNSIGNED', connection);
}
}

80
src/models/AuthToken.ts Normal file
View File

@ -0,0 +1,80 @@
import Model from "wms-core/db/Model";
import AuthProof from "wms-core/auth/AuthProof";
import UserEmail from "wms-core/auth/models/UserEmail";
import User from "wms-core/auth/models/User";
import Validator from "wms-core/db/Validator";
import {cryptoRandomDictionary} from "wms-core/Utils";
export default class AuthToken extends Model implements AuthProof {
public static async getBySecret(secret: string): Promise<AuthToken | null> {
const models = await this.models<AuthToken>(this.select().where('secret', secret).first());
return models.length > 0 ? models[0] : null;
}
public static async getForUser(user_id: number): Promise<AuthToken[]> {
return await this.models<AuthToken>(this.select().where('user_id', user_id));
}
protected readonly user_id!: number;
protected readonly secret!: string;
protected created_at?: Date;
protected used_at?: Date;
protected readonly ttl!: number;
constructor(props: any) {
super(props);
if (!this.secret) {
this.secret = cryptoRandomDictionary(64, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_');
}
}
protected defineProperties() {
this.defineProperty('user_id', new Validator().defined().exists(User, 'id'));
this.defineProperty('secret', new Validator().defined().between(32, 64));
this.defineProperty('created_at', new Validator());
this.defineProperty('used_at', new Validator());
this.defineProperty('ttl', new Validator().defined().min(1).max(5 * 365 * 24 * 3600)); // max 5 years
}
public use() {
this.used_at = new Date();
}
public canDelete(user_id: number) {
return this.user_id === user_id;
}
public getExpirationDate(): Date {
if (!this.created_at) return new Date();
return new Date(this.created_at.getTime() + this.ttl * 1000);
}
public async getEmail(): Promise<string> {
let userEmail = await UserEmail.getMainFromUser(this.user_id);
if (!userEmail) throw new Error("Cannot find main user email for user " + this.user_id);
return userEmail.email;
}
public async getUser(): Promise<User | null> {
return await User.getById<User>(`${this.user_id}`);
}
public async isAuthorized(): Promise<boolean> {
return true;
}
public async isOwnedBy(userId: number): Promise<boolean> {
return this.user_id === userId;
}
public async isValid(): Promise<boolean> {
return new Date().getTime() < this.getExpirationDate().getTime();
}
public async revoke(session: Express.Session): Promise<void> {
await this.delete();
}
}

66
src/models/FileModel.ts Normal file
View File

@ -0,0 +1,66 @@
import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import Controller from "wms-core/Controller";
import config from "config";
import User from "wms-core/auth/models/User";
import {Request} from "express";
export default class FileModel extends Model {
public static get table(): string {
return 'files';
}
public static async getBySlug(slug: string): Promise<FileModel | null> {
const models = await this.models<FileModel>(this.select().where('slug', slug).first());
return models.length > 0 ? models[0] : null;
}
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));
}
public readonly user_id!: number;
public readonly slug!: string;
public readonly real_name!: string;
public readonly storage_type!: FileStorage;
public readonly storage_path!: string;
public readonly size!: number;
public created_at?: Date;
public readonly ttl!: number;
protected defineProperties() {
this.defineProperty('user_id', new Validator().defined().exists(User, 'id'));
this.defineProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(this, 'slug'));
this.defineProperty('real_name', new Validator().defined().minLength(1).maxLength(259));
this.defineProperty('storage_type', new Validator().defined().maxLength(64));
this.defineProperty('storage_path', new Validator().defined().maxLength(1745));
this.defineProperty('size', new Validator().defined().min(0));
this.defineProperty('created_at', new Validator());
this.defineProperty('ttl', new Validator().defined().min(0).max(4294967295));
}
public getURL(): string {
return config.get<string>('base_url') + Controller.route('get-file', {
slug: this.slug,
});
}
public getExpirationDate(): Date | null {
if (!this.created_at) return new Date();
if (this.ttl === 0) return null;
return new Date(this.created_at.getTime() + this.ttl * 1000);
}
public shouldBeDeleted(): boolean {
const expirationDate = this.getExpirationDate();
if (!expirationDate) return false;
return new Date().getTime() >= expirationDate.getTime();
}
public canDelete(user_id: number): boolean {
return this.user_id === user_id;
}
}
export type FileStorage = 'local';

0
storage/.gitkeep Normal file
View File

1
storage/tmp/.gitkeep Normal file
View File

@ -0,0 +1 @@
/*

0
storage/uploads/.gitkeep Normal file
View File

View File

@ -1,14 +1,14 @@
{% extends 'layouts/base.njk' %}
{% set title = 'Example App - About us' %}
{% set title = 'ily.li - About' %}
{% block body %}
<h1>Very interesting</h1>
<h1>Self-hosting files</h1>
<div class="container">
<section class="panel">
<h2>This is us</h2>
<p class="center">And we like wms!</p>
<h2>Details</h2>
<p class="center">Powered by wms-core</p>
</section>
</div>
{% endblock %}

42
views/auth.njk Normal file
View File

@ -0,0 +1,42 @@
{% extends 'layouts/base.njk' %}
{% import 'macros.njk' as macros %}
{% set title = 'Authentication / Registration' %}
{% set decription = 'Join Watch My Stream and power up your streams!' %}
{% set h1 = 'Authentication and registration' %}
{% block body %}
<div class="container">
<div class="panel">
{% if register_confirm_email %}
<form action="/auth" method="POST" id="register-form">
<h2>Register</h2>
{{ macros.message('question', 'Do you wish to create a new account with ' + register_confirm_email + '?', false, false) }}
{{ macros.message('warning', 'If you already have an account, please log in with your existing email first and then add your new email in the Account page.', false, true) }}
<input type="hidden" name="email" value="{{ register_confirm_email }}">
<input type="hidden" name="confirm_register" value="confirm">
<div class="form-field">
<div class="form-display">Email: {{ register_confirm_email }}</div>
</div>
<a href="/auth" class="button transparent">Go back</a>
<button type="submit" class="primary">Register</button>
{{ macros.csrf(getCSRFToken) }}
</form>
{% else %}
<form action="/auth" method="POST" id="login-form">
<h2>Log in or register</h2>
{# {{ macros.message('info', 'If we don\'t find your email address in our database, you will be able to register.', false, true) }}#}
<div class="input-field">
{{ macros.field(_locals, 'email', 'email', query.email or '', 'Your email address', "If we don't find your email address in our database, you will be able to register.", 'required') }}
</div>
<button type="submit">Authenticate</button>
{{ macros.csrf(getCSRFToken) }}
</form>
{% endif %}
</div>
</div>
{% endblock %}

49
views/file-manager.njk Normal file
View File

@ -0,0 +1,49 @@
{% extends 'layouts/base.njk' %}
{% set title = 'ily.li - File manager' %}
{% block scripts %}
<script src="/js/fm.js"></script>
{% endblock %}
{% block body %}
<h1>File manager</h1>
<p>You're their manager, please be nice with them.</p>
<section class="panel">
<h2>File list</h2>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>URL</th>
<th>Name</th>
<th>Size</th>
<th>Expires at</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td>{{ file.id }}</td>
<td>
<div class="copyable-text">
<a class="content" href="{{ file.getURL() }}" target="_blank">{{ file.getURL() }}</a>
<button class="copy-button"><i data-feather="copy"></i></button>
</div>
</td>
<td>{{ file.real_name }}</td>
<td>{{ (file.size / (1024 * 1024)).toFixed(2) }}MB</td>
{% set expires_at = file.getExpirationDate() %}
<td>{% if expires_at %}{{ expires_at.toISOString() }}{% else %}Never{% endif %}</td>
<td>
<a href="{{ route('delete-file-frontend', file.slug) }}" class="button danger"><i data-feather="trash"></i> Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

141
views/file-upload.njk Normal file
View File

@ -0,0 +1,141 @@
{% extends 'layouts/base.njk' %}
{% set title = 'ily.li - File manager' %}
{% block scripts %}
<script src="/js/fm.js"></script>
{% endblock %}
{% block body %}
<h1>Upload files</h1>
<p>(except illegal ones)</p>
<div class="container">
<section class="panel">
<h2>Upload a file</h2>
<form action="{{ route('post-file-frontend') }}" method="POST" enctype="multipart/form-data"
id="upload-form">
{{ macros.field(_locals, 'file', 'upload', '', 'Choose wisely', 'The maximum upload size is ' + max_upload_size + 'MiB', validation_attributes='required') }}
{{ 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, 'checkbox', 'autogen_url', '', 'Generate url automatically', null, validation_attributes='checked') }}
{{ macros.csrf(getCSRFToken) }}
<button type="submit"><i data-feather="upload"></i> Upload</button>
</form>
<div id="file-upload" class="hidden">
<div class="name">photo.jpg</div>
<div class="progress-bar" style="--progress: 50%;"><span class="content">50%</span></div>
<div class="status hidden"></div>
</div>
<div id="file-upload-link" class="hidden copyable-text">
<div class="title">URL</div>
<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>
<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>
</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>
{% endblock %}

View File

@ -1,7 +0,0 @@
{% extends 'layouts/base.njk' %}
{% set title = 'Example App - Hello world!' %}
{% block body %}
<h1>Hello world!</h1>
{% endblock %}

View File

@ -12,10 +12,17 @@
{% endblock %}
{% block header %}
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> Example app</a>
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> ily.li</a>
<nav>
<ul>
<li><a href="{{ route('about') }}"><i data-feather="info"></i> About</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('logout') }}"><i data-feather="log-out"></i> Logout</a></li>
{% else %}
<li><a href="{{ route('auth') }}"><i data-feather="user"></i> Login / Register</a></li>
{% endif %}
</ul>
</nav>
{% endblock %}
@ -37,4 +44,4 @@
</main>
{% endblock %}
{% block footer %}Example app v{{ app_version }} - all rights reserved.{% endblock %}
{% block footer %}ily.li v{{ app_version }} - all rights reserved.{% endblock %}

19
views/magic_link.njk Normal file
View File

@ -0,0 +1,19 @@
{% extends 'layouts/base.njk' %}
{% import 'macros.njk' as macros %}
{% set actionType = magicLink.action_type %}
{% set title = 'WMS: Magic Link' + (' - ' + actionType if actionType) %}
{% set h1 = 'Magic Link' + (' - ' + actionType if actionType) %}
{% block body %}
<div class="container">
<div class="panel">
{% if err %}
{{ macros.message('error', err) }}
{% else %}
{{ macros.message('success', 'Success!') }}
<p>You can now close this page.</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends 'layouts/base.njk' %}
{% import 'macros.njk' as macros %}
{% set title = 'Authentication lobby' %}
{% set h1 = 'Authentication lobby' %}
{% block body %}
<div class="container">
<div class="panel">
{{ macros.message('success', 'We sent a link to ' + email + '. To authenticate, open it from any device.') }}
{{ macros.message('info', 'This link will be valid for <span id="countdown"></span> and can only be used once.', true, true) }}
<p class="center">Waiting for you to open the link...</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const validUntil = {{ validUntil }}.0;
function isValid() {
return new Date().getTime() < validUntil;
}
function websocketListen(websocket, e) {
if (e.data === 'refresh') {
window.location.reload();
}
}
document.addEventListener('DOMContentLoaded', () => {
const countdown = document.getElementById('countdown');
if (!isValid()) return;
function animateCountdown() {
requestAnimationFrame(() => {
let diff = Math.max(0, validUntil - new Date().getTime());
if (diff === 0) {
return;
}
diff /= 1000;
const seconds = Math.floor(diff % 60).toFixed(0);
const minutes = Math.floor((diff - seconds) / 60).toFixed(0);
countdown.innerText = `${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
animateCountdown();
});
}
animateCountdown();
});
</script>
{{ macros.websocket(websocketUrl, 'websocketListen', 1, 'isValid') }}
{% endblock %}

View File

@ -0,0 +1,115 @@
{% if not text %}
<mjml>
<mj-head>
<mj-title>{{ mail_subject }}</mj-title>
<mj-font
name="Nunito"
href="https://fonts.googleapis.com/css2?family=Nunito"
/>
<mj-attributes>
<mj-all
color="#f0f0f0"
font-size="16px"
font-family="Nunito, Helvetica, sans-serif"
padding="0"
/>
<mj-section
full-width="full-width"
padding="16px 16px"
background-color="#1e2932"
/>
<mj-button
background-color="#842cff"
text-transform="uppercase"
inner-padding="16px 32px"
border-radius="50px"
font-weight="700"
padding="32px"
/>
<mj-text
padding="8px 0px"
/>
<mj-class name="header"
align="center"
font-size="20px"
background-color="#1b252d"
/>
<mj-class name="footer"
align="center"
background-color="#151d23"
font-size="14px"
/>
<mj-class name="title"
align="center"
font-size="28px"
padding-bottom="24px"
align="center"
/>
<mj-class name="subtitle"
align="center"
font-size="22px"
padding-bottom="16px"
align="center"
/>
<mj-class name="link"
color="#00766c"
/>
<mj-class name="important-line"
font-size="20px"
padding="16px 0px"
/>
</mj-attributes>
<mj-style inline="inline">
.link {
color: #00766c !important;
text-decoration: none;
}
</mj-style>
<mj-style>
.link:hover {
color: #00a99b !important;
}
</mj-style>
{% block head %}{% endblock %}
</mj-head>
<mj-body width="632px">
{% if mail_link %}
<mj-section background-color="#171f26" padding="2px 0px">
<mj-column>
<mj-text align="center" font-size="12px">
Does this mail display improperly?
<a href="{{ mail_link }}" class="link">Open it in the browser</a>
</mj-text>
</mj-column>
</mj-section>
{% endif %}
<mj-section mj-class="header">
<mj-column>
<mj-text mj-class="header">
ily.li
</mj-text>
</mj-column>
</mj-section>
{% block body %}{% endblock %}
<mj-section mj-class="footer">
<mj-column>
<mj-text mj-class="footer">
All rights reserved. Contact us at
<a href="mailto:(contact email)" class="link">(contact email)</a>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
{% else %}
{% block text %}{% endblock %}
{% endif %}

View File

@ -0,0 +1,66 @@
{% extends './base_layout.mjml.njk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
{% if type == 'register' %}
Register an account on (app)
{% else %}
Log in to (app)
{% endif %}
</mj-text>
<mj-text>
{% if type == 'register' %}
Someone has requested an account registration for <strong>{{ mail_to }}</strong>. If it was not you,
please ignore this message.
{% else %}
Someone is attempting to log in to your account <strong>{{ mail_to }}</strong>.
{% endif %}
</mj-text>
{% if type == 'register' %}
<mj-button href="{{ link | safe }}">Finalize my account registration</mj-button>
{% else %}
<mj-text>If it is not you, <strong>DO NOT CLICK ON THIS BUTTON</strong>.</mj-text>
{% endif %}
</mj-column>
</mj-section>
{% if type == 'login' %}
<mj-section background-color="#1b252d">
<mj-column>
<mj-text mj-class="important-line" padding-bottom="0px">
IP: <strong>{{ ip }}</strong>
</mj-text>
<mj-text mj-class="important-line">
Location: <strong>{{ geo }}</strong>
</mj-text>
</mj-column>
<mj-column>
<mj-button href="{{ link | safe }}" padding="20px 0" background-color="#caa200">
Authorize log in
</mj-button>
</mj-column>
</mj-section>
{% endif %}
{% endblock %}
{% block text %}
{% if type == 'register' %}
Hi!
Someone requested an account registration for {{ mail_to }}. If it was not you,
please ignore this message.
To finalize your account registration, please follow this link: {{ link|safe }}
{% else %}
Hi!
Someone is attempting to log in to your account {{ mail_to }}.
If it is not you, DO NOT FOLLOW THIS LINK.
IP: {{ ip }}
Location: {{ geo }}
To authorize this log in, please follow this link: {{ link|safe }}
{% endif %}
{% endblock %}

10121
yarn.lock Normal file

File diff suppressed because it is too large Load Diff