Merge branch 'develop'
This commit is contained in:
commit
92c258805d
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,3 +3,5 @@ node_modules
|
||||
public
|
||||
dist
|
||||
yarn-error.log
|
||||
storage/tmp
|
||||
storage/uploads
|
16
app.service
Normal file
16
app.service
Normal 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
|
@ -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",
|
||||
|
@ -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 |
@ -1,6 +1,7 @@
|
||||
import './external_links';
|
||||
import './message_icons';
|
||||
import './forms';
|
||||
import './copyable_text';
|
||||
|
||||
import '../sass/app.scss';
|
||||
|
||||
|
12
assets/js/copyable_text.js
Normal file
12
assets/js/copyable_text.js
Normal 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
132
assets/js/fm.js
Normal 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;
|
||||
}
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
$primary: darken(#242b33, 2%);
|
||||
$primaryForeground: #f0f0f0;
|
||||
$secondary: lighten(#00766c, 10%);
|
||||
$secondary: lighten(#842cff, 10%);
|
||||
$secondaryForeground: $primaryForeground;
|
||||
|
||||
$backgroundColor: darken($primary, 4%);
|
||||
|
@ -1 +1,2 @@
|
||||
@import "layout";
|
||||
@import "layout";
|
||||
@import "fm";
|
11
assets/sass/fm.scss
Normal file
11
assets/sass/fm.scss
Normal file
@ -0,0 +1,11 @@
|
||||
@import "vars";
|
||||
|
||||
#file-upload {
|
||||
padding: 8px;
|
||||
background: $infoColor;
|
||||
border-radius: 5px;
|
||||
|
||||
.name, .status {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
});
|
@ -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
|
||||
});
|
@ -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
|
||||
},
|
||||
});
|
14
package.json
14
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
6
src/MagicLinkMail.ts
Normal 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'
|
||||
);
|
13
src/controllers/AboutController.ts
Normal file
13
src/controllers/AboutController.ts
Normal 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');
|
||||
}
|
||||
}
|
8
src/controllers/AuthController.ts
Normal file
8
src/controllers/AuthController.ts
Normal 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);
|
||||
}
|
||||
}
|
182
src/controllers/FileController.ts
Normal file
182
src/controllers/FileController.ts
Normal 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');
|
@ -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');
|
||||
}
|
||||
}
|
17
src/controllers/MagicLinkActionType.ts
Normal file
17
src/controllers/MagicLinkActionType.ts
Normal 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 '';
|
||||
}
|
27
src/controllers/MagicLinkController.ts
Normal file
27
src/controllers/MagicLinkController.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
21
src/migrations/CreateAuthTokensTable.ts
Normal file
21
src/migrations/CreateAuthTokensTable.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
24
src/migrations/CreateFilesTable.ts
Normal file
24
src/migrations/CreateFilesTable.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
12
src/migrations/IncreaseFilesSizeField.ts
Normal file
12
src/migrations/IncreaseFilesSizeField.ts
Normal 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
80
src/models/AuthToken.ts
Normal 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
66
src/models/FileModel.ts
Normal 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
0
storage/.gitkeep
Normal file
1
storage/tmp/.gitkeep
Normal file
1
storage/tmp/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
/*
|
0
storage/uploads/.gitkeep
Normal file
0
storage/uploads/.gitkeep
Normal 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
42
views/auth.njk
Normal 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
49
views/file-manager.njk
Normal 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
141
views/file-upload.njk
Normal 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 %}
|
@ -1,7 +0,0 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = 'Example App - Hello world!' %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Hello world!</h1>
|
||||
{% endblock %}
|
@ -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
19
views/magic_link.njk
Normal 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 %}
|
58
views/magic_link_lobby.njk
Normal file
58
views/magic_link_lobby.njk
Normal 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 %}
|
115
views/mails/base_layout.mjml.njk
Normal file
115
views/mails/base_layout.mjml.njk
Normal 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 %}
|
66
views/mails/magic_link.mjml.njk
Normal file
66
views/mails/magic_link.mjml.njk
Normal 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 %}
|
Loading…
Reference in New Issue
Block a user