Initial files

This commit is contained in:
Alice Gaudon 2020-06-14 13:01:52 +02:00
parent 8149ecac71
commit b8c1ed1139
24 changed files with 10703 additions and 94 deletions

View File

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

View File

@ -1,71 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
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"
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">
<metadata
id="metadata12">
<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" />
<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: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" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-share-2"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 445 B

View File

@ -1,2 +1,19 @@
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,
});

View File

@ -1,2 +1,17 @@
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,
});

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

@ -44,7 +44,9 @@
"wms-core": "^0"
},
"dependencies": {
"@types/express-fileupload": "^1.1.3",
"config": "^3.3.1",
"express": "^4.17.1"
"express": "^4.17.1",
"express-fileupload": "^1.1.7-alpha.3"
}
}

View File

@ -16,11 +16,27 @@ 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";
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 +47,10 @@ export default class ExampleApp extends Application {
return [
CreateMigrationsTable,
CreateLogsTable,
CreateUsersAndUserEmailsTable,
CreateMagicLinksTable,
CreateAuthTokensTable,
CreateFilesTable,
];
}
@ -66,6 +86,20 @@ 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) {
return await AuthToken.getBySecret(authorization);
}
return super.getProofForRequest(req);
}
}));
// Utils
this.use(new RedirectBackComponent());
@ -79,9 +113,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());
// Other functionality
this.use(new FileController());
// Semi-static
this.use(new AboutController());
}
}

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

@ -1,16 +1,11 @@
import Controller from "wms-core/Controller";
import {Request, Response} from "express";
export default class HomeController extends Controller {
export default class AboutController 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,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,96 @@
import Controller from "wms-core/Controller";
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import {BadRequestError, NotFoundHttpError, ServerError} from "wms-core/HttpError";
import FileModel from "../models/FileModel";
import fileUpload from "express-fileupload";
import {cryptoRandomDictionary} from "wms-core/Utils";
import config from "config";
const FILE_UPLOAD_MIDDLEWARE = fileUpload({
tempFileDir: 'storage/tmp',
useTempFiles: true,
abortOnLimit: true,
createParentPath: true,
});
const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export default class FileController extends Controller {
routes(): void {
this.get('/f/:slug', this.downloadFile, 'get-file');
this.post('/f', this.postFile, 'post-file', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_MIDDLEWARE);
this.put('/f/:slug', this.putFile, 'put-file', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_MIDDLEWARE);
this.delete('/f/:slug', this.deleteFile, 'delete-file', REQUIRE_AUTH_MIDDLEWARE);
}
protected async downloadFile(req: Request, res: Response): Promise<void> {
const file = await FileModel.getBySlug(req.params.slug);
if (!file) 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(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> {
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;
if (Array.isArray(upload) && upload.length !== 1) {
throw new BadRequestError((req.files ? req.files.length : 0) + ' files received.', 'You must upload exactly one (1) file.', req.url);
}
if (Array.isArray(upload)) upload = upload[0];
const file = new FileModel({
slug: slug,
real_name: upload.name,
storage_type: 'local',
storage_path: 'storage/uploads/' + slug,
});
await file.save();
await upload.mv(file.storage_path);
res.format({
json: () => res.json({
url: file.getURL(),
}),
text: () => res.send(file.getURL()),
html: () => res.render('upload_success', {url: file.getURL()}),
});
}
protected async deleteFile(req: Request, res: Response): Promise<void> {
const file = await FileModel.getBySlug(req.params.slug);
if (!file) throw new NotFoundHttpError('File', req.url);
}
private async generateSlug(tries: number): Promise<string> {
let i = 0;
do {
const slug = cryptoRandomDictionary(config.get<number>('newlyGeneratedSlugSize'), SLUG_DICTIONARY);
if (!await FileModel.getBySlug(slug)) {
return slug;
}
i++;
} while (i < tries)
throw new ServerError('Failed to generate slug; newly generated slug size should be increased by 1.');
}
}

View File

@ -0,0 +1,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,8 @@
import Logger from "wms-core/Logger";
import ExampleApp from "./ExampleApp";
import App from "./App";
(async () => {
const app = new ExampleApp(4899);
const app = new App(4899);
await app.start();
})().catch(err => {
Logger.error(err);

View File

@ -0,0 +1,20 @@
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(),' +
'ttl INT 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,20 @@
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,' +
'slug VARCHAR(259) UNIQUE NOT NULL,' +
'real_name VARCHAR(259) NOT NULL,' +
'storage_type VARCHAR(64) NOT NULL,' +
'storage_path VARCHAR(1745) NOT NULL,' +
'PRIMARY KEY (id)' +
')', connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE IF EXISTS files', connection);
}
}

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

@ -0,0 +1,57 @@
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";
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;
}
protected readonly user_id!: number;
protected readonly secret!: string;
protected created_at?: Date;
protected readonly ttl!: number;
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('ttl', new Validator().defined().min(1).max(5 * 365 * 24 * 3600)); // max 5 years
}
public getExpirationDate(): Date {
if (!this.created_at) return new Date();
return new Date(this.created_at.getTime() + this.ttl);
}
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();
}
}

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

@ -0,0 +1,35 @@
import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import Controller from "wms-core/Controller";
import config from "config";
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 readonly slug!: string;
public readonly real_name!: string;
public readonly storage_type!: FileStorage;
public readonly storage_path!: string;
protected defineProperties() {
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));
}
public getURL(): string {
return config.get<string>('base_url') + Controller.route('get-file', {
slug: this.slug,
});
}
}
export type FileStorage = 'local';

0
storage/.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 %}

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

@ -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="#00a99b"
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 %}

10135
yarn.lock Normal file

File diff suppressed because it is too large Load Diff