Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2020-11-22 15:20:19 +01:00
commit d08a132549
59 changed files with 3294 additions and 3360 deletions

114
.eslintrc.json Normal file
View File

@ -0,0 +1,114 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"parserOptions": {
"project": [
"./tsconfig.json",
"./tsconfig.test.json",
"./tsconfig.frontend.json"
]
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-trailing-spaces": "error",
"max-len": [
"error",
{
"code": 120,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"semi": "off",
"@typescript-eslint/semi": [
"error"
],
"no-extra-semi": "error",
"eol-last": "error",
"comma-dangle": "off",
"@typescript-eslint/comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "always-multiline",
"enums": "always-multiline",
"generics": "always-multiline",
"tuples": "always-multiline"
}
],
"no-extra-parens": "off",
"@typescript-eslint/no-extra-parens": [
"error"
],
"no-nested-ternary": "error",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/explicit-module-boundary-types": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-non-null-assertion": "error",
"no-useless-return": "error",
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": [
"error"
],
"no-return-await": "off",
"@typescript-eslint/return-await": [
"error",
"always"
],
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/no-floating-promises": "error"
},
"ignorePatterns": [
"jest.config.js",
"webpack.config.js",
"dist/**/*",
"public/**/*",
"config/**/*"
],
"overrides": [
{
"files": [
"test/**/*"
],
"rules": {
"max-len": [
"error",
{
"code": 120,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true,
"ignoreStrings": true
}
]
}
}
]
}

View File

@ -1 +1,3 @@
# ily.li is a simple file self-hosting solution # ily.li is a simple file self-hosting solution
Boilerplate for a quickstart with [swaf](https://eternae.ink/arisu/swaf)

View File

@ -1,3 +1,6 @@
# Please customize values i.e. paths, user, group, WorkingDirectory based on your environment. Do not use the same
# user and group for different applications.
[Unit] [Unit]
Description=ily.li website Description=ily.li website
After=network-online.target After=network-online.target

View File

@ -1,8 +1,8 @@
{ {
"bundles": { "bundles": {
"app": "js/app.js", "app": "ts/app.ts",
"fm": "js/fm.js", "fm": "ts/fm.ts",
"url-shrinker": "js/url-shrinker.js", "url-shrinker": "ts/url-shrinker.ts",
"layout": "sass/layout.scss", "layout": "sass/layout.scss",
"error": "sass/error.scss", "error": "sass/error.scss",
"logo": "img/logo.svg", "logo": "img/logo.svg",

View File

@ -1,12 +0,0 @@
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');
});
});
});

View File

@ -1,131 +0,0 @@
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) {
const time = new Date().getTime();
if (this.lastTransferTime) {
this.xferSpeed.push((loaded - this.lastLoaded) / (time - this.lastTransferTime));
if (this.xferSpeed.length > 100) 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

@ -1,29 +0,0 @@
// For labels to update their state (css selectors based on the value attribute)
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('input').forEach(el => {
if (el.type !== 'checkbox') {
el.setAttribute('value', el.value);
el.addEventListener('change', () => {
el.setAttribute('value', el.value);
});
}
});
});
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,17 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
const menuButton = document.getElementById('menu-button');
const mainMenu = document.getElementById('main-menu');
menuButton.addEventListener('click', (e) => {
e.stopPropagation();
mainMenu.classList.toggle('open');
});
mainMenu.addEventListener('click', (e) => {
e.stopPropagation();
});
document.addEventListener('click', () => {
mainMenu.classList.remove('open');
});
});

View File

@ -1,11 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('url-shrink-form');
if (!form) return;
const autogenUrlCheckbox = document.getElementById('field-autogen_url');
const slugField = document.getElementById('field-slug');
autogenUrlCheckbox.addEventListener('change', () => {
slugField.disabled = autogenUrlCheckbox.checked;
});
});

View File

@ -9,6 +9,7 @@ $defaultTextColor: #ffffff;
$headerBackground: darken($primary, 7.5%); $headerBackground: darken($primary, 7.5%);
$footerBackground: lighten($headerBackground, 1%); $footerBackground: lighten($headerBackground, 1%);
$panelBackground: lighten($headerBackground, 1%); $panelBackground: lighten($headerBackground, 1%);
$inputBackground: darken($panelBackground, 4%);
$info: #4499ff; $info: #4499ff;
$infoText: darken($info, 42%); $infoText: darken($info, 42%);
@ -27,4 +28,4 @@ $errorText: darken($error, 30%);
$errorColor: desaturate($errorText, 50%); $errorColor: desaturate($errorText, 50%);
// Responsivity // Responsivity
$menuLayoutSwitchTreshold: 700px; $mobileThreshold: 632px;

View File

@ -21,7 +21,53 @@ body {
background-color: $backgroundColor; background-color: $backgroundColor;
} }
header { @mixin tip {
position: relative;
.tip {
visibility: hidden;
position: absolute;
z-index: 10000;
pointer-events: none;
display: block;
width: max-content;
height: 30px;
padding: 4px 8px;
line-height: 22px;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
text-align: center;
font-size: 18px;
color: $defaultTextColor;
opacity: 0;
transition: opacity ease-out 100ms, visibility step-end 150ms;
transition-delay: 0ms;
background-color: #000;
border-radius: 5px;
text-transform: initial;
font-weight: initial;
&.top {
top: auto;
bottom: calc(100% + 8px);
}
}
&:hover, &:active {
.tip {
visibility: visible;
opacity: 1;
transition: opacity ease-out 100ms;
transition-delay: 150ms;
}
}
}
body > header {
z-index: 50;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@ -61,6 +107,7 @@ header {
font-size: 20px; font-size: 20px;
li { li {
position: relative;
list-style: none; list-style: none;
a, button { a, button {
@ -77,13 +124,6 @@ header {
&:not(button) { &:not(button) {
background-color: rgba(255, 255, 255, 0.07); background-color: rgba(255, 255, 255, 0.07);
} }
.tip {
visibility: visible;
opacity: 1;
transition: opacity ease-out 100ms;
transition-delay: 150ms;
}
} }
.feather { .feather {
@ -99,11 +139,6 @@ header {
.feather { .feather {
margin-right: 0; margin-right: 0;
} }
.tip {
text-transform: initial;
font-weight: initial;
}
} }
form { form {
@ -112,6 +147,34 @@ header {
align-items: center; align-items: center;
padding: 0; padding: 0;
} }
&.auth-user {
img {
width: 48px;
height: 48px;
border-radius: 3px;
margin-right: 8px;
}
}
.dropdown {
position: absolute;
z-index: -1;
top: 100%;
right: 0;
white-space: nowrap;
background: $headerBackground;
border-radius: 0 0 3px 3px;
a {
padding: 0 8px;
}
}
&:hover .dropdown {
display: block;
}
} }
} }
@ -120,7 +183,7 @@ header {
} }
} }
@media (max-width: $menuLayoutSwitchTreshold) { @media (max-width: $mobileThreshold) {
flex-direction: row-reverse; flex-direction: row-reverse;
.logo { .logo {
@ -149,7 +212,7 @@ header {
} }
} }
ul { > ul {
flex-direction: column; flex-direction: column;
position: absolute; position: absolute;
z-index: 10; z-index: 10;
@ -172,39 +235,25 @@ header {
font-weight: inherit; font-weight: inherit;
} }
} }
}
}
}
}
@media (min-width: $menuLayoutSwitchTreshold) { .dropdown {
nav ul li { position: initial;
a, button {
.tip {
visibility: hidden;
position: absolute;
display: block; display: block;
width: max-content; padding-left: 32px;
height: 30px;
padding: 4px 8px;
line-height: 22px;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
text-align: center;
font-size: 18px;
color: $defaultTextColor;
opacity: 0;
transition: opacity ease-out 100ms, visibility step-end 150ms;
transition-delay: 0ms;
background-color: #000;
border-radius: 5px;
} }
} }
}
}
}
@media (min-width: $mobileThreshold) {
nav ul li {
a, button, .button {
@include tip;
}
&:last-child { &:last-child {
a, button { a, button, .button {
.tip { .tip {
left: unset; left: unset;
right: 4px; right: 4px;
@ -225,7 +274,11 @@ footer {
main { main {
flex: 1; flex: 1;
padding: 8px; padding: 8px 0;
button, .button {
@include tip;
}
} }
h1 { h1 {
@ -252,15 +305,23 @@ section > h2, .panel > h2 {
align-items: center; align-items: center;
position: relative; position: relative;
text-align: center; text-align: center;
margin-top: 16px; margin-top: 4px;
&::before, &::after { font-size: 24px;
line-height: 1;
.feather {
margin: 0 16px 0 0;
opacity: 0.1;
}
&::after {
content: ""; content: "";
flex: 1; flex: 1;
margin: 0 32px; margin: 0 16px;
height: 0; height: 0;
border-bottom: 1px solid $defaultTextColor; border-bottom: 1px solid $defaultTextColor;
opacity: 0.2; opacity: 0.1;
} }
} }
@ -277,7 +338,7 @@ a {
text-decoration: none; text-decoration: none;
&:hover { &:hover {
color: lighten($secondary, 5%); color: lighten($secondary, 10%);
} }
.feather.feather-external-link { .feather.feather-external-link {
@ -292,11 +353,27 @@ form {
text-align: center; text-align: center;
.form-field { .form-field {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 16px auto; margin: 16px auto;
.control {
position: relative;
background: $inputBackground;
border-radius: 5px;
}
.feather.icon {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
z-index: 0;
--icon-size: 24px;
opacity: 0.75;
}
label { label {
position: absolute; position: absolute;
left: 8px; left: 8px;
@ -321,11 +398,11 @@ form {
} }
} }
input, select, .input-group { input, select, textarea, .input-group {
z-index: 1;
border: 0; border: 0;
color: $defaultTextColor; color: $defaultTextColor;
background: lighten($panelBackground, 4%); background: transparent;
border-radius: 5px;
font-size: 16px; font-size: 16px;
&:focus, &:not([value=""]), &[type="file"] { &:focus, &:not([value=""]), &[type="file"] {
@ -336,10 +413,11 @@ form {
} }
} }
input, select, .form-display { input, select, textarea, .form-display {
display: block; display: block;
padding: 32px 8px 8px 8px; padding: 32px 8px 8px 8px;
width: 100%; width: 100%;
height: 60px;
} }
select { select {
@ -353,9 +431,9 @@ form {
& + .feather { & + .feather {
position: absolute; position: absolute;
z-index: -1; pointer-events: none;
right: 8px; right: 8px;
bottom: 8px; top: 30px;
transition: transform 150ms ease-out; transition: transform 150ms ease-out;
} }
@ -366,30 +444,48 @@ form {
} }
} }
textarea {
resize: vertical;
min-height: 100px;
font-family: inherit;
}
input[type=color] { input[type=color] {
height: calc(32px + 8px + 32px); height: calc(32px + 8px + 32px);
} }
&.inline { &.inline {
display: flex;
flex-direction: row; flex-direction: row;
.control {
display: flex;
flex-direction: row;
align-items: center;
flex-grow: 1;
input[type=checkbox] { input[type=checkbox] {
text-align: left;
width: min-content; width: min-content;
height: min-content; height: min-content;
margin: 8px;
text-align: left;
& ~ label { & ~ label {
position: static; position: static;
flex-grow: 1;
display: inline; display: inline;
padding-left: 8px; padding: 8px;
font-size: 16px; font-size: 16px;
text-align: left;
}
} }
} }
} }
.input-group { .input-group {
display: flex; display: flex;
flex-shrink: 1; flex-grow: 1;
flex-direction: row; flex-direction: row;
div { div {
@ -398,19 +494,9 @@ form {
input { input {
width: 100%; width: 100%;
margin-top: 24px;
padding-top: 8px;
border: 0; border: 0;
background: transparent; background: transparent;
} }
> input + * {
position: absolute;
top: 32px;
right: 28px;
user-select: none;
text-align: right;
}
} }
} }
} }
@ -418,7 +504,7 @@ form {
.inline-fields { .inline-fields {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: start;
margin: 16px auto; margin: 16px auto;
.form-field { .form-field {
@ -547,8 +633,17 @@ button, .button {
} }
} }
.max-content { .breadcrumb {
width: max-content; list-style: none;
display: flex;
flex-direction: row;
margin: 0;
padding: 8px;
> *:not(:first-child)::before {
content: '';
padding: 0 8px;
}
} }
// --- // ---
@ -558,14 +653,27 @@ button, .button {
text-align: center; text-align: center;
} }
.container { @mixin container {
width: $mobileThreshold;
padding: 0 16px; padding: 0 16px;
max-width: 632px;
@media (min-width: $mobileThreshold) {
margin: 0 auto; margin: 0 auto;
}
@media (max-width: $mobileThreshold) {
width: 100%;
padding: 0 8px;
}
}
.container {
@include container;
} }
.panel { .panel {
margin: 16px 0; position: relative;
margin: 16px 0 48px;
padding: 8px; padding: 8px;
background-color: $panelBackground; background-color: $panelBackground;
border-radius: 5px; border-radius: 5px;
@ -573,6 +681,14 @@ button, .button {
p { p {
margin: 16px 8px; margin: 16px 8px;
} }
> .feather:first-child {
position: absolute;
--icon-size: 24px;
opacity: 0.1;
top: 8px;
left: 8px;
}
} }
.sub-panel { .sub-panel {
@ -587,10 +703,16 @@ button, .button {
// --- Feather // --- Feather
// --- // ---
.feather { .feather {
display: inline-flex;
justify-content: center;
align-items: center;
flex-shrink: 0; flex-shrink: 0;
--icon-size: 24px;
width: var(--icon-size); width: var(--icon-size);
height: var(--icon-size); height: var(--icon-size);
--icon-size: 16px;
font-size: var(--icon-size);
stroke: currentColor; stroke: currentColor;
stroke-width: 2; stroke-width: 2;
stroke-linecap: square; stroke-linecap: square;

View File

@ -0,0 +1,39 @@
export default class PersistentWebsocket {
private webSocket?: WebSocket;
public constructor(
protected readonly url: string,
private readonly handler: MessageHandler,
protected readonly reconnectOnClose: boolean = true,
) {
}
public run(): void {
const _webSocket = this.webSocket = new WebSocket(this.url);
this.webSocket.addEventListener('open', () => {
console.debug('Websocket connected');
});
this.webSocket.addEventListener('message', (e) => {
this.handler(_webSocket, e);
});
this.webSocket.addEventListener('error', (e) => {
console.error('Websocket error', e);
});
this.webSocket.addEventListener('close', (e) => {
this.webSocket = undefined;
console.debug('Websocket closed', e.code, e.reason);
if (this.reconnectOnClose) {
setTimeout(() => this.run(), 1000);
}
});
}
public send(data: string): void {
if (!this.webSocket) throw new Error('WebSocket not connected');
this.webSocket.send(data);
}
}
export type MessageHandler = (webSocket: WebSocket, e: MessageEvent) => void;

View File

@ -2,8 +2,9 @@ import './external_links';
import './message_icons'; import './message_icons';
import './forms'; import './forms';
import './copyable_text'; import './copyable_text';
import './tooltips-and-dropdowns';
import './main_menu'; import './main_menu';
import './font-awesome';
// css
import '../sass/app.scss'; import '../sass/app.scss';
console.log('Hello world!');

View File

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

View File

@ -2,7 +2,9 @@ import feather from "feather-icons";
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('a[target="_blank"]').forEach(el => { document.querySelectorAll('a[target="_blank"]').forEach(el => {
if (!el.classList.contains('no-icon')) {
el.innerHTML += `<i data-feather="external-link"></i>`; el.innerHTML += `<i data-feather="external-link"></i>`;
}
}); });
feather.replace(); feather.replace();

171
assets/ts/fm.ts Normal file
View File

@ -0,0 +1,171 @@
import {applyFormMessages} from "./forms";
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector<HTMLFormElement>('#upload-form');
if (!form) return;
const upload = document.getElementById('file-upload');
const uploadLink = document.querySelector<HTMLLinkElement>('#file-upload-link');
const uploadField = document.querySelector<HTMLInputElement>('#field-upload');
const neverExpireCheckbox = document.querySelector<HTMLInputElement>('#field-never_expire');
const expireAfterDaysField = document.querySelector<HTMLInputElement>('#field-expire_after_days');
const autogenUrlCheckbox = document.querySelector<HTMLInputElement>('#field-autogen_url');
const slugField = document.querySelector<HTMLInputElement>('#field-slug');
if (expireAfterDaysField) {
neverExpireCheckbox?.addEventListener('change', () => {
expireAfterDaysField.disabled = neverExpireCheckbox.checked;
});
}
if (slugField) {
autogenUrlCheckbox?.addEventListener('change', () => {
slugField.disabled = autogenUrlCheckbox.checked;
});
}
let uploadForm: UploadForm | undefined;
form.addEventListener('submit', e => {
e.preventDefault();
if (upload && uploadLink && uploadField && uploadField.files && (!uploadForm || uploadForm.isFinished())) {
uploadForm = new UploadForm(form, upload, uploadLink, uploadField.files[0].name);
uploadForm.updateView();
uploadForm.start();
}
});
});
const units = ['K', 'M', 'G', 'T'];
class UploadForm {
private finished: boolean = false;
private readonly progressBar: HTMLElement | null;
private readonly progressBarContent: HTMLElement | null;
private readonly status: HTMLElement | null;
private speed: HTMLElement | null;
private xferSpeed: number[] = [];
private lastTransferTime: number | null = null;
private lastLoaded: number = 0;
private xhr: XMLHttpRequest;
public constructor(
private form: HTMLFormElement,
private upload: HTMLElement,
private uploadLink: HTMLLinkElement,
private fileName: string,
) {
this.progressBar = this.upload.querySelector('.progress-bar');
this.progressBarContent = this.progressBar?.querySelector('.content') || null;
this.status = this.upload.querySelector('.status');
this.speed = this.status?.querySelector('.speed') || null;
if (!this.progressBar)
throw new Error('Invalid html');
this.xhr = new XMLHttpRequest();
this.xhr.responseType = 'json';
this.xhr.upload.addEventListener('progress', e => {
if (this.progressBar && this.progressBarContent) {
if (e.lengthComputable) {
this.progressBar.classList.remove('undefined');
const percent = (e.loaded / e.total * 100).toFixed(2) + '%';
this.progressBar.style.setProperty('--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;
const response = this.xhr.response;
console.log('done', response);
if (response.status === 'error') {
if (response.messages) {
this.restoreView();
applyFormMessages(this.form, response.messages);
}
} else if (response.url) {
if (this.status) {
this.status.innerHTML = 'Done!';
}
const uploadLinkContent = this.uploadLink.querySelector<HTMLElement>('.content');
if (uploadLinkContent) {
uploadLinkContent.innerText = response.url;
}
this.uploadLink.classList.remove('hidden');
} else {
window.location.reload();
}
});
this.xhr.addEventListener('error', (e) => {
this.finished = true;
console.error('error', e);
if (this.status) {
this.status.innerHTML = 'Error; upload was interrupted.';
}
});
}
public isFinished(): boolean {
return this.finished;
}
public updateView(): void {
const uploadName = this.upload.querySelector<HTMLElement>('.name');
if (uploadName) {
uploadName.innerText = this.fileName;
}
this.form.classList.add('hidden');
this.upload.classList.remove('hidden');
if (this.status) {
this.status.innerHTML = `Uploading @ <span class="speed">--</span>`;
this.speed = this.status.querySelector('.speed');
}
}
public restoreView(): void {
if (this.status) {
this.status.classList.add('hidden');
}
this.upload.classList.add('hidden');
this.form.classList.remove('hidden');
}
public start(): void {
const formData = new FormData(this.form);
this.xhr.open('POST', this.form.action);
this.xhr.send(formData);
}
public updateSpeed(loaded: number): void {
const time = new Date().getTime();
if (this.lastTransferTime) {
this.xferSpeed.push((loaded - this.lastLoaded) / (time - this.lastTransferTime));
if (this.xferSpeed.length > 100) 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++;
}
if (this.speed) {
this.speed.innerText = speed.toFixed(2) + units[unit] + 'Bps';
}
}
this.lastTransferTime = time;
this.lastLoaded = loaded;
}
}

View File

@ -0,0 +1,4 @@
import '../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss';
import '../../node_modules/@fortawesome/fontawesome-free/scss/regular.scss';
import '../../node_modules/@fortawesome/fontawesome-free/scss/solid.scss';
import '../../node_modules/@fortawesome/fontawesome-free/scss/brands.scss';

43
assets/ts/forms.ts Normal file
View File

@ -0,0 +1,43 @@
/*
* For labels to update their state (css selectors based on the value attribute)
*/
import {ValidationError} from "swaf/db/Validator";
export function updateInputs(): void {
document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input, textarea').forEach(el => {
if (!el.dataset.inputSetup) {
el.dataset.inputSetup = 'true';
if (el.type !== 'checkbox') {
el.setAttribute('value', el.value);
el.addEventListener('change', () => {
el.setAttribute('value', el.value);
});
}
}
});
}
document.addEventListener('DOMContentLoaded', () => {
updateInputs();
});
export function applyFormMessages(
formElement: HTMLFormElement,
messages: { [p: string]: ValidationError<unknown> },
): void {
for (const fieldName of Object.keys(messages)) {
const field = formElement.querySelector('#field-' + fieldName);
if (!field) continue;
let parent = field.parentElement;
while (parent && !parent.classList.contains('form-field')) parent = parent.parentElement;
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}`;
}
}

21
assets/ts/main_menu.ts Normal file
View File

@ -0,0 +1,21 @@
document.addEventListener('DOMContentLoaded', () => {
const menuButton = document.getElementById('menu-button');
const mainMenu = document.getElementById('main-menu');
if (menuButton) {
menuButton.addEventListener('click', (e) => {
e.stopPropagation();
mainMenu?.classList.toggle('open');
});
}
if (mainMenu) {
mainMenu.addEventListener('click', (e) => {
e.stopPropagation();
});
document.addEventListener('click', () => {
mainMenu.classList.remove('open');
});
}
});

View File

@ -1,19 +1,24 @@
import feather from "feather-icons"; import feather from "feather-icons";
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const messageTypeToIcon = { const messageTypeToIcon: { [p: string]: string } = {
info: 'info', info: 'info',
success: 'check', success: 'check',
warning: 'alert-triangle', warning: 'alert-triangle',
error: 'x-circle', error: 'x-circle',
question: 'help-circle', question: 'help-circle',
}; };
document.querySelectorAll('.message').forEach(el => {
const type = el.dataset['type']; document.querySelectorAll<HTMLElement>('.message').forEach(el => {
const icon = el.querySelector('.icon'); const icon = el.querySelector('.icon');
const type = el.dataset['type'];
if (!icon || !type) return;
if (!messageTypeToIcon[type]) throw new Error(`No icon for type ${type}`);
const svgContainer = document.createElement('div'); const svgContainer = document.createElement('div');
svgContainer.innerHTML = feather.icons[messageTypeToIcon[type]].toSvg(); svgContainer.innerHTML = feather.icons[messageTypeToIcon[type]].toSvg();
el.insertBefore(svgContainer.firstChild, icon);
if (svgContainer.firstChild) el.insertBefore(svgContainer.firstChild, icon);
icon.remove(); icon.remove();
}); });

View File

@ -0,0 +1,31 @@
export function updateTooltips(): void {
console.debug('Updating tooltips');
const elements = document.querySelectorAll<HTMLElement>('.tip, .dropdown');
// Calculate max potential displacement
let max = 0;
elements.forEach(el => {
const box = el.getBoundingClientRect();
if (max < box.height) max = box.height;
});
// Prevent displacement
elements.forEach(el => {
if (!el.dataset.tooltipSetup) {
el.dataset.tooltipSetup = 'true';
const box = el.getBoundingClientRect();
if (box.bottom >= document.body.clientHeight - (max + 32)) {
el.classList.add('top');
}
}
});
}
document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('popstate', () => {
updateTooltips();
});
window.requestAnimationFrame(() => {
updateTooltips();
});
});

13
assets/ts/url-shrinker.ts Normal file
View File

@ -0,0 +1,13 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('url-shrink-form');
if (!form) return;
const autogenUrlCheckbox = document.querySelector<HTMLInputElement>('#field-autogen_url');
const slugField = document.querySelector<HTMLInputElement>('#field-slug');
if (slugField) {
autogenUrlCheckbox?.addEventListener('change', () => {
slugField.disabled = autogenUrlCheckbox.checked;
});
}
});

View File

@ -1,27 +1,31 @@
export default Object.assign(require("wms-core/config/default").default, { {
app: { app: {
name: 'ily.li', name: 'ily.li',
contact_email: 'contact@ily.li' contact_email: 'contact@ily.li',
}, },
domain: 'localhost', log_level: "DEV",
base_url: 'http://localhost:4893', db_log_level: "ERROR",
public_websocket_url: 'ws://localhost:4893', public_url: "http://localhost:4899",
port: 4893, public_websocket_url: "ws://localhost:4899",
port: 4899,
mysql: { mysql: {
connectionLimit: 10, connectionLimit: 10,
host: "localhost", host: "localhost",
user: "root", user: "root",
password: "", password: "",
database: "ilyli", database: "ilyli",
create_database_automatically: false create_database_automatically: false,
},
redis: {
host: "127.0.0.1",
port: 6379,
prefix: 'ilyli',
},
session: {
cookie: {
secure: false,
}, },
magic_link: {
validity_period: 20
}, },
newlyGeneratedSlugSize: 3,
default_file_ttl: 30, // 30 seconds
max_upload_size: 1, // MB
approval_mode: false,
mail: { mail: {
host: "127.0.0.1", host: "127.0.0.1",
port: "1025", port: "1025",
@ -32,10 +36,22 @@ export default Object.assign(require("wms-core/config/default").default, {
from: 'contact@ily.li', from: 'contact@ily.li',
from_name: 'ily.li', from_name: 'ily.li',
}, },
view: {
cache: false,
},
magic_link: {
validity_period: 20,
},
newlyGeneratedSlugSize: 3,
default_file_ttl: 30, // 30 seconds
max_upload_size: 16, // MB
max_hotlink_size: 1, // MB
approval_mode: false,
domain: 'localhost',
allowed_url_domains: [ allowed_url_domains: [
'localhost:4893', 'localhost:4899',
'127.0.0.1:4893', '127.0.0.1:4899',
], ],
default_url_domain_for_files: 0, default_url_domain_for_files: 0,
default_url_domain_for_urls: 1, default_url_domain_for_urls: 1,
}); }

View File

@ -1,29 +1,30 @@
export default Object.assign(require("wms-core/config/production").default, { {
domain: 'ily.li', log_level: "DEBUG",
base_url: 'https://ily.li', db_log_level: "ERROR",
public_url: 'https://ily.li',
public_websocket_url: 'wss://ily.li', public_websocket_url: 'wss://ily.li',
mysql: { session: {
connectionLimit: 10, cookie: {
host: "localhost", secure: true
user: "root",
password: "",
database: "ilyli",
}, },
magic_link: {
validity_period: 900
}, },
newlyGeneratedSlugSize: 5,
default_file_ttl: 30 * 24 * 3600, // 30 days
max_upload_size: 8192, // MB
approval_mode: true,
mail: { mail: {
secure: true, secure: true,
allow_invalid_tls: false allow_invalid_tls: false
}, },
magic_link: {
validity_period: 900,
},
newlyGeneratedSlugSize: 5,
default_file_ttl: 2592000, // 30 days
max_upload_size: 8192, // MB
max_hotlink_size: 128, // MB
approval_mode: true,
domain: 'ily.li',
allowed_url_domains: [ allowed_url_domains: [
'ily.li', 'ily.li',
'gris.li', 'gris.li',
], ],
default_url_domain_for_files: 0, default_url_domain_for_files: 0,
default_url_domain_for_urls: 1, default_url_domain_for_urls: 1,
}); }

9
config/test.json5 Normal file
View File

@ -0,0 +1,9 @@
{
mysql: {
host: "localhost",
user: "root",
password: "",
database: "ilyli_test",
create_database_automatically: true,
},
}

View File

@ -1,9 +0,0 @@
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,34 +1,42 @@
{ {
"name": "ily.li", "name": "ily.li",
"version": "0.5.3", "version": "0.5.4",
"description": "Self-hosted file pusher", "description": "Self-hosted file pusher",
"repository": "https://gitlab.com/ArisuOngaku/ily.li", "repository": "https://eternae.ink/arisu/ily.li",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"main": "dist/main.js", "main": "dist/src/main.js",
"scripts": { "scripts": {
"test": "jest --verbose --runInBand",
"dist-webpack": "webpack --mode production", "dist-webpack": "webpack --mode production",
"dist": "tsc && npm run dist-webpack", "clean": "(test ! -d dist || rm -r dist)",
"compile": "yarn clean && tsc",
"build": "yarn compile && yarn dist-webpack",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"dev": "concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"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" "start": "yarn build && node dist/src/main.js",
"test": "jest --verbose --runInBand"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.0", "@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.5", "@babel/preset-env": "^7.9.5",
"@fortawesome/fontawesome-free": "^5.14.0",
"@types/config": "^0.0.36", "@types/config": "^0.0.36",
"@types/express": "^4.17.6", "@types/express": "^4.17.6",
"@types/express-session": "^1.17.0", "@types/express-session": "^1.17.0",
"@types/feather-icons": "^4.7.0",
"@types/formidable": "^1.0.31", "@types/formidable": "^1.0.31",
"@types/jest": "^26.0.4", "@types/jest": "^26.0.4",
"@types/mysql": "^2.15.15", "@types/mysql": "^2.15.15",
"@types/node": "^14.0.27", "@types/node": "^14.6.3",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3", "@types/nunjucks": "^3.1.3",
"@types/ws": "^7.2.6", "@types/ws": "^7.2.6",
"@typescript-eslint/eslint-plugin": "^4.3.0",
"@typescript-eslint/parser": "^4.3.0",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"concurrently": "^5.1.0", "concurrently": "^5.1.0",
"css-loader": "^4.2.1", "css-loader": "^5.0.0",
"eslint": "^7.10.0",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"imagemin": "^7.0.1", "imagemin": "^7.0.1",
@ -38,20 +46,21 @@
"imagemin-svgo": "^8.0.0", "imagemin-svgo": "^8.0.0",
"img-loader": "^3.0.1", "img-loader": "^3.0.1",
"jest": "^26.1.0", "jest": "^26.1.0",
"mini-css-extract-plugin": "^0.11.0", "mini-css-extract-plugin": "^1.2.1",
"node-sass": "^4.14.0", "node-sass": "^5.0.0",
"nodemon": "^2.0.3", "nodemon": "^2.0.3",
"sass-loader": "^10.0.1", "sass-loader": "^10.0.1",
"terser-webpack-plugin": "^5.0.3",
"ts-jest": "^26.1.1", "ts-jest": "^26.1.1",
"ts-loader": "^8.0.4",
"typescript": "^4.0.2", "typescript": "^4.0.2",
"uglifyjs-webpack-plugin": "^2.2.0", "webpack": "^5.3.2",
"webpack": "^4.43.0", "webpack-cli": "^4.1.0"
"webpack-cli": "^3.3.11",
"wms-core": "^0.21.4"
}, },
"dependencies": { "dependencies": {
"config": "^3.3.1", "config": "^3.3.1",
"express": "^4.17.1", "express": "^4.17.1",
"formidable": "^1.2.2" "formidable": "^1.2.2",
"swaf": "^0.22.5"
} }
} }

View File

@ -1,59 +1,60 @@
import Application from "wms-core/Application"; import Application from "swaf/Application";
import {Type} from "wms-core/Utils"; import Migration, {MigrationType} from "swaf/db/Migration";
import Migration from "wms-core/db/Migration"; import CreateMigrationsTable from "swaf/migrations/CreateMigrationsTable";
import CreateMigrationsTable from "wms-core/migrations/CreateMigrationsTable"; import ExpressAppComponent from "swaf/components/ExpressAppComponent";
import CreateLogsTable from "wms-core/migrations/CreateLogsTable"; import NunjucksComponent from "swaf/components/NunjucksComponent";
import ExpressAppComponent from "wms-core/components/ExpressAppComponent"; import MysqlComponent from "swaf/components/MysqlComponent";
import NunjucksComponent from "wms-core/components/NunjucksComponent"; import LogRequestsComponent from "swaf/components/LogRequestsComponent";
import MysqlComponent from "wms-core/components/MysqlComponent"; import RedisComponent from "swaf/components/RedisComponent";
import LogRequestsComponent from "wms-core/components/LogRequestsComponent"; import ServeStaticDirectoryComponent from "swaf/components/ServeStaticDirectoryComponent";
import RedisComponent from "wms-core/components/RedisComponent"; import MaintenanceComponent from "swaf/components/MaintenanceComponent";
import ServeStaticDirectoryComponent from "wms-core/components/ServeStaticDirectoryComponent"; import MailComponent from "swaf/components/MailComponent";
import MaintenanceComponent from "wms-core/components/MaintenanceComponent"; import SessionComponent from "swaf/components/SessionComponent";
import MailComponent from "wms-core/components/MailComponent"; import FormHelperComponent from "swaf/components/FormHelperComponent";
import SessionComponent from "wms-core/components/SessionComponent"; import CsrfProtectionComponent from "swaf/components/CsrfProtectionComponent";
import FormHelperComponent from "wms-core/components/FormHelperComponent"; import WebSocketServerComponent from "swaf/components/WebSocketServerComponent";
import CsrfProtectionComponent from "wms-core/components/CsrfProtectionComponent";
import WebSocketServerComponent from "wms-core/components/WebSocketServerComponent";
import AboutController from "./controllers/AboutController"; import AboutController from "./controllers/AboutController";
import AutoUpdateComponent from "wms-core/components/AutoUpdateComponent"; import AutoUpdateComponent from "swaf/components/AutoUpdateComponent";
import AuthController from "./controllers/AuthController"; import AuthController from "./controllers/AuthController";
import MagicLinkWebSocketListener from "wms-core/auth/magic_link/MagicLinkWebSocketListener"; import MagicLinkWebSocketListener from "swaf/auth/magic_link/MagicLinkWebSocketListener";
import MagicLinkController from "./controllers/MagicLinkController"; import MagicLinkController from "./controllers/MagicLinkController";
import MailController from "wms-core/auth/MailController"; import MailController from "swaf/auth/MailController";
import FileController from "./controllers/FileController"; import FileController from "./controllers/FileController";
import CreateUsersAndUserEmailsTable from "wms-core/auth/migrations/CreateUsersAndUserEmailsTable"; import CreateUsersAndUserEmailsTable from "swaf/auth/migrations/CreateUsersAndUserEmailsTable";
import CreateMagicLinksTable from "wms-core/auth/migrations/CreateMagicLinksTable"; import CreateMagicLinksTable from "swaf/auth/migrations/CreateMagicLinksTable";
import CreateAuthTokensTable from "./migrations/CreateAuthTokensTable"; import CreateAuthTokensTable from "./migrations/CreateAuthTokensTable";
import AuthComponent from "wms-core/auth/AuthComponent"; import AuthComponent from "swaf/auth/AuthComponent";
import AuthGuard from "wms-core/auth/AuthGuard"; import AuthGuard from "swaf/auth/AuthGuard";
import MagicLink from "wms-core/auth/models/MagicLink"; import MagicLink from "swaf/auth/models/MagicLink";
import AuthToken from "./models/AuthToken"; import AuthToken from "./models/AuthToken";
import {MagicLinkActionType} from "./controllers/MagicLinkActionType"; import {MagicLinkActionType} from "./controllers/MagicLinkActionType";
import {Request} from "express"; import {Request} from "express";
import CreateFilesTable from "./migrations/CreateFilesTable"; import CreateFilesTable from "./migrations/CreateFilesTable";
import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField"; import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField";
import AddApprovedFieldToUsersTable from "wms-core/auth/migrations/AddApprovedFieldToUsersTable"; import AddApprovedFieldToUsersTable from "swaf/auth/migrations/AddApprovedFieldToUsersTable";
import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable"; import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable";
import AuthTokenController from "./controllers/AuthTokenController"; import AuthTokenController from "./controllers/AuthTokenController";
import URLRedirectController from "./controllers/URLRedirectController"; import URLRedirectController from "./controllers/URLRedirectController";
import LinkController from "./controllers/LinkController"; import LinkController from "./controllers/LinkController";
import BackendController from "wms-core/helpers/BackendController"; import BackendController from "swaf/helpers/BackendController";
import RedirectBackComponent from "wms-core/components/RedirectBackComponent"; import RedirectBackComponent from "swaf/components/RedirectBackComponent";
import DummyMigration from "swaf/migrations/DummyMigration";
import DropLegacyLogsTable from "swaf/migrations/DropLegacyLogsTable";
import {Session} from "express-session";
import packageJson = require('../package.json');
export default class App extends Application { export default class App extends Application {
private readonly port: number; public constructor(
private magicLinkWebSocketListener?: MagicLinkWebSocketListener; private readonly addr: string,
private readonly port: number,
constructor(port: number) { ) {
super(require('../package.json').version); super(packageJson.version);
this.port = port;
} }
protected getMigrations(): Type<Migration>[] { protected getMigrations(): MigrationType<Migration>[] {
return [ return [
CreateMigrationsTable, CreateMigrationsTable,
CreateLogsTable, DummyMigration,
CreateUsersAndUserEmailsTable, CreateUsersAndUserEmailsTable,
CreateMagicLinksTable, CreateMagicLinksTable,
CreateAuthTokensTable, CreateAuthTokensTable,
@ -61,6 +62,7 @@ export default class App extends Application {
IncreaseFilesSizeField, IncreaseFilesSizeField,
AddApprovedFieldToUsersTable, AddApprovedFieldToUsersTable,
CreateUrlRedirectsTable, CreateUrlRedirectsTable,
DropLegacyLogsTable,
]; ];
} }
@ -74,7 +76,7 @@ export default class App extends Application {
const redisComponent = new RedisComponent(); const redisComponent = new RedisComponent();
const mysqlComponent = new MysqlComponent(); const mysqlComponent = new MysqlComponent();
const expressAppComponent = new ExpressAppComponent(this.port); const expressAppComponent = new ExpressAppComponent(this.addr, this.port);
this.use(expressAppComponent); this.use(expressAppComponent);
// Base // Base
@ -102,8 +104,11 @@ export default class App extends Application {
this.use(redisComponent); this.use(redisComponent);
this.use(new SessionComponent(redisComponent)); this.use(new SessionComponent(redisComponent));
this.use(new AuthComponent(new class extends AuthGuard<MagicLink | AuthToken> { this.use(new AuthComponent(new class extends AuthGuard<MagicLink | AuthToken> {
public async getProofForSession(session: Express.Session): Promise<any | null> { public async getProofForSession(session: Session): Promise<MagicLink | AuthToken | null> {
return await MagicLink.bySessionID(session.id, [MagicLinkActionType.LOGIN, MagicLinkActionType.REGISTER]); return await MagicLink.bySessionId(
session.id,
[MagicLinkActionType.LOGIN, MagicLinkActionType.REGISTER],
);
} }
public async getProofForRequest(req: Request): Promise<MagicLink | AuthToken | null> { public async getProofForRequest(req: Request): Promise<MagicLink | AuthToken | null> {
@ -117,9 +122,9 @@ export default class App extends Application {
return token; return token;
} }
return super.getProofForRequest(req); return await super.getProofForRequest(req);
} }
})); }(this)));
// Utils // Utils
this.use(new FormHelperComponent()); this.use(new FormHelperComponent());
@ -132,8 +137,7 @@ export default class App extends Application {
} }
private registerWebSocketListeners() { private registerWebSocketListeners() {
this.magicLinkWebSocketListener = new MagicLinkWebSocketListener(); this.use(new MagicLinkWebSocketListener());
this.use(this.magicLinkWebSocketListener);
} }
private registerControllers() { private registerControllers() {
@ -142,7 +146,7 @@ export default class App extends Application {
// Priority // Priority
this.use(new AuthController()); this.use(new AuthController());
this.use(new MagicLinkController(this.magicLinkWebSocketListener!)); this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));
this.use(new BackendController()); this.use(new BackendController());
// Core functionality // Core functionality

View File

@ -1,7 +1,7 @@
import {cryptoRandomDictionary} from "wms-core/Utils"; import {cryptoRandomDictionary} from "swaf/Utils";
import config from "config"; import config from "config";
import FileModel from "./models/FileModel"; import FileModel from "./models/FileModel";
import {ServerError} from "wms-core/HttpError"; import {ServerError} from "swaf/HttpError";
const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export default async function generateSlug(tries: number): Promise<string> { export default async function generateSlug(tries: number): Promise<string> {
@ -14,4 +14,4 @@ export default async function generateSlug(tries: number): Promise<string> {
i++; i++;
} while (i < tries); } while (i < tries);
throw new ServerError('Failed to generate slug; newly generated slug size should be increased by 1.'); throw new ServerError('Failed to generate slug; newly generated slug size should be increased by 1.');
}; }

View File

@ -1,4 +1,4 @@
export function encodeRFC5987ValueChars(str: string) { export function encodeRFC5987ValueChars(str: string): string {
return encodeURIComponent(str). return encodeURIComponent(str).
// Note that although RFC3986 reserves "!", RFC5987 does not, // Note that although RFC3986 reserves "!", RFC5987 does not,
// so we do not need to escape it // so we do not need to escape it

View File

@ -1,8 +1,8 @@
import Controller from "wms-core/Controller"; import Controller from "swaf/Controller";
import {Request, Response} from "express"; import {Request, Response} from "express";
export default class AboutController extends Controller { export default class AboutController extends Controller {
routes(): void { public routes(): void {
this.get('/', this.getAbout, 'home'); this.get('/', this.getAbout, 'home');
this.get('/', this.getAbout, 'about'); this.get('/', this.getAbout, 'about');
} }

View File

@ -1,5 +1,5 @@
import MagicLinkAuthController from "wms-core/auth/magic_link/MagicLinkAuthController"; import MagicLinkAuthController from "swaf/auth/magic_link/MagicLinkAuthController";
import {MAGIC_LINK_MAIL} from "wms-core/Mails"; import {MAGIC_LINK_MAIL} from "swaf/Mails";
export default class AuthController extends MagicLinkAuthController { export default class AuthController extends MagicLinkAuthController {
public constructor() { public constructor() {

View File

@ -1,18 +1,19 @@
import Controller from "wms-core/Controller"; import Controller from "swaf/Controller";
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent"; import {RequireAuthMiddleware} from "swaf/auth/AuthComponent";
import {Request, Response} from "express"; import {Request, Response} from "express";
import AuthToken from "../models/AuthToken"; import AuthToken from "../models/AuthToken";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "wms-core/HttpError"; import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "swaf/HttpError";
export default class AuthTokenController extends Controller { export default class AuthTokenController extends Controller {
routes(): void { public routes(): void {
this.post('/gen-auth-token', this.postGenAuthToken, 'generate-token', REQUIRE_AUTH_MIDDLEWARE); this.post('/gen-auth-token', this.postGenAuthToken, 'generate-token', RequireAuthMiddleware);
this.post('/revoke-auth-token/:id', this.postRevokeAuthToken, 'revoke-token', REQUIRE_AUTH_MIDDLEWARE); this.post('/revoke-auth-token/:id', this.postRevokeAuthToken, 'revoke-token', RequireAuthMiddleware);
} }
protected async postGenAuthToken(req: Request, res: Response): Promise<void> { protected async postGenAuthToken(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const authToken = AuthToken.create({ const authToken = AuthToken.create({
user_id: req.models.user!.id, user_id: user.id,
ttl: req.body.ttl ? parseInt(req.body.ttl) : 365 * 24 * 3600, ttl: req.body.ttl ? parseInt(req.body.ttl) : 365 * 24 * 3600,
}); });
await authToken.save(); await authToken.save();
@ -26,7 +27,9 @@ export default class AuthTokenController extends Controller {
const authToken = await AuthToken.getById<AuthToken>(parseInt(id)); const authToken = await AuthToken.getById<AuthToken>(parseInt(id));
if (!authToken) throw new NotFoundHttpError('Auth token', req.url); if (!authToken) throw new NotFoundHttpError('Auth token', req.url);
if (!authToken.canDelete(req.models.user!.id!)) throw new ForbiddenHttpError('auth token', req.url);
const user = req.as(RequireAuthMiddleware).getUser();
if (!authToken.canDelete(user.getOrFail('id'))) throw new ForbiddenHttpError('auth token', req.url);
await authToken.delete(); await authToken.delete();

View File

@ -1,31 +1,32 @@
import Controller from "wms-core/Controller"; import Controller from "swaf/Controller";
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent"; import {RequireAuthMiddleware} from "swaf/auth/AuthComponent";
import {NextFunction, Request, Response} from "express"; import {NextFunction, Request, Response} from "express";
import {BadRequestError, ForbiddenHttpError, ServerError} from "wms-core/HttpError"; import {BadRequestError, ForbiddenHttpError, ServerError} from "swaf/HttpError";
import FileModel from "../models/FileModel"; import FileModel from "../models/FileModel";
import config from "config"; import config from "config";
import * as fs from "fs"; import * as fs from "fs";
import AuthToken from "../models/AuthToken"; import AuthToken from "../models/AuthToken";
import {IncomingForm} from "formidable"; import {IncomingForm} from "formidable";
import generateSlug from "../SlugGenerator"; import generateSlug from "../SlugGenerator";
import Logger from "wms-core/Logger"; import {log} from "swaf/Logger";
import FileUploadMiddleware from "wms-core/FileUploadMiddleware"; import FileUploadMiddleware from "swaf/FileUploadMiddleware";
export default class FileController extends Controller { export default class FileController extends Controller {
routes(): void { public routes(): void {
this.get('/files/upload', this.getFileUploader, 'file-upload', REQUIRE_AUTH_MIDDLEWARE); this.get('/files/upload', this.getFileUploader, 'file-upload', RequireAuthMiddleware);
this.get('/files/upload/script', this.downloadLinuxScript, 'file-linux-script'); this.get('/files/upload/script', this.downloadLinuxScript, 'file-linux-script');
this.post('/files/post', this.postFileFrontend, 'post-file-frontend', REQUIRE_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE); this.post('/files/post', this.postFileFrontend, 'post-file-frontend', RequireAuthMiddleware, FileUploadFormMiddleware);
this.get('/files/:page([0-9]+)?', this.getFileManager, 'file-manager', REQUIRE_AUTH_MIDDLEWARE); this.get('/files/:page([0-9]+)?', this.getFileManager, 'file-manager', RequireAuthMiddleware);
this.post('/files/delete/:slug', FileController.deleteFileRoute, 'delete-file-frontend', REQUIRE_AUTH_MIDDLEWARE); this.post('/files/delete/:slug', FileController.deleteFileRoute, 'delete-file-frontend', RequireAuthMiddleware);
} }
protected async getFileUploader(req: Request, res: Response): Promise<void> { protected async getFileUploader(req: Request, res: Response): Promise<void> {
const allowedDomains = config.get<string[]>('allowed_url_domains'); const allowedDomains = config.get<string[]>('allowed_url_domains');
const user = req.as(RequireAuthMiddleware).getUser();
res.render('file-upload', { res.render('file-upload', {
max_upload_size: config.get<string>('max_upload_size'), max_upload_size: config.get<string>('max_upload_size'),
auth_tokens: await AuthToken.select().where('user_id', req.models.user!.id!).get(), auth_tokens: await AuthToken.select().where('user_id', user.id).get(),
allowed_domains: allowedDomains, allowed_domains: allowedDomains,
default_domain: allowedDomains[config.get<number>('default_url_domain_for_files')], default_domain: allowedDomains[config.get<number>('default_url_domain_for_files')],
}); });
@ -36,23 +37,30 @@ export default class FileController extends Controller {
} }
protected async getFileManager(req: Request, res: Response): Promise<void> { protected async getFileManager(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
res.render('file-manager', { res.render('file-manager', {
files: await FileModel.paginateForUser(req, 100, req.models.user!.id!), files: await FileModel.paginateForUser(req, 100, user.getOrFail('id')),
}); });
} }
protected async postFileFrontend(req: Request, res: Response): Promise<void> { protected async postFileFrontend(req: Request, res: Response): Promise<void> {
req.body.type = 'file'; req.body.type = 'file';
await FileController.handleFileUpload(req.body.autogen_url === undefined && req.body.slug ? req.body.slug : await generateSlug(10), req, res); await FileController.handleFileUpload(
req.body.autogen_url === undefined && req.body.slug ?
req.body.slug :
await generateSlug(10),
req,
res,
);
} }
public static async handleFileUpload(slug: string, req: Request, res: Response): Promise<void> { public static async handleFileUpload(slug: string, req: Request, res: Response): Promise<void> {
// Check for file upload // Check for file upload
if (!req.files || !req.files['upload']) { if (Object.keys(req.files).indexOf('upload') < 0) {
throw new BadRequestError('No file received.', 'You must upload exactly one (1) file.', req.url); throw new BadRequestError('No file received.', 'You must upload exactly one (1) file.', req.url);
} }
let upload = req.files['upload']; const upload = req.files['upload'];
// TTL // TTL
let ttl = config.get<number>('default_file_ttl'); let ttl = config.get<number>('default_file_ttl');
@ -60,8 +68,10 @@ export default class FileController extends Controller {
if (req.body.ttl !== undefined) ttl = parseInt(req.body.ttl); 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; else if (req.body.expire_after_days !== undefined) ttl = parseInt(req.body.expire_after_days) * 24 * 3600;
const user = req.as(RequireAuthMiddleware).getUser();
const file = FileModel.create({ const file = FileModel.create({
user_id: req.models.user!.id, user_id: user.id,
slug: slug, slug: slug,
real_name: upload.name, real_name: upload.name,
storage_type: 'local', storage_type: 'local',
@ -71,7 +81,7 @@ export default class FileController extends Controller {
}); });
await file.save(); await file.save();
fs.renameSync(upload.path, file.storage_path!); fs.renameSync(upload.path, file.getOrFail('storage_path'));
const domain = req.body.url_domain || config.get<string[]>('allowed_url_domains')[config.get<number>('default_url_domain_for_files')]; const domain = req.body.url_domain || config.get<string[]>('allowed_url_domains')[config.get<number>('default_url_domain_for_files')];
res.format({ res.format({
@ -93,7 +103,9 @@ export default class FileController extends Controller {
const file = await FileModel.getBySlug(req.params.slug); const file = await FileModel.getBySlug(req.params.slug);
if (!file) return next(); if (!file) return next();
if (!file.canDelete(req.models.user!.id!)) throw new ForbiddenHttpError('file', req.url);
const user = req.as(RequireAuthMiddleware).getUser();
if (!file.canDelete(user.getOrFail('id'))) throw new ForbiddenHttpError('file', req.url);
switch (file.storage_type) { switch (file.storage_type) {
case 'local': case 'local':
@ -116,15 +128,22 @@ export default class FileController extends Controller {
} }
public static async deleteFile(file: FileModel): Promise<void> { public static async deleteFile(file: FileModel): Promise<void> {
fs.unlinkSync(file.storage_path!); fs.unlinkSync(file.getOrFail('storage_path'));
await file.delete(); await file.delete();
Logger.info('Deleted', file.storage_path, `(${file.real_name})`); log.info('Deleted', file.storage_path, `(${file.real_name})`);
} }
} }
export const FILE_UPLOAD_FORM_MIDDLEWARE = new FileUploadMiddleware(() => { export class FileUploadFormMiddleware extends FileUploadMiddleware {
protected getDefaultField(): string {
return 'upload';
}
protected makeForm(): IncomingForm {
const form = new IncomingForm(); const form = new IncomingForm();
form.uploadDir = 'storage/tmp'; form.uploadDir = 'storage/tmp';
form.maxFileSize = config.get<number>('max_upload_size') * 1024 * 1024; form.maxFileSize = config.get<number>('max_upload_size') * 1024 * 1024;
return form; return form;
}, 'upload'); }
}

View File

@ -0,0 +1,25 @@
import Controller from "swaf/Controller";
import {Request, Response} from "express";
export default class HomeController extends Controller {
public routes(): void {
this.get('/', this.getHome, 'home');
this.get('/about', this.getAbout, 'about');
this.get('/back', this.goBack, 'about');
}
protected async getHome(req: Request, res: Response): Promise<void> {
res.render('home');
}
protected async getAbout(req: Request, res: Response): Promise<void> {
res.render('about');
}
/**
* This is to test and assert that swaf extended types are available
*/
protected async goBack(req: Request, res: Response): Promise<void> {
res.redirectBack();
}
}

View File

@ -1,36 +1,34 @@
import Controller from "wms-core/Controller"; import Controller from "swaf/Controller";
import {NextFunction, Request, Response} from "express"; import {NextFunction, Request, Response} from "express";
import {BadRequestError, NotFoundHttpError, ServerError} from "wms-core/HttpError"; import {BadRequestError, NotFoundHttpError, ServerError} from "swaf/HttpError";
import config from "config"; import config from "config";
import {REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent"; import {RequireRequestAuthMiddleware} from "swaf/auth/AuthComponent";
import URLRedirect from "../models/URLRedirect"; import URLRedirect from "../models/URLRedirect";
import URLRedirectController from "./URLRedirectController"; import URLRedirectController from "./URLRedirectController";
import FileModel from "../models/FileModel"; import FileModel from "../models/FileModel";
import generateSlug from "../SlugGenerator"; import generateSlug from "../SlugGenerator";
import FileController, {FILE_UPLOAD_FORM_MIDDLEWARE} from "./FileController"; import FileController, {FileUploadFormMiddleware} from "./FileController";
import * as fs from "fs"; import * as fs from "fs";
import {encodeRFC5987ValueChars} from "../Utils"; import {encodeRFC5987ValueChars} from "../Utils";
import {promisify} from "util"; import {promisify} from "util";
import * as buffer from "buffer"; import {log} from "swaf/Logger";
import Logger from "wms-core/Logger";
export default class LinkController extends Controller { export default class LinkController extends Controller {
routes(): void { public routes(): void {
this.post('/', this.postFile, 'post-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE); this.post('/', this.postFile, 'post-file', RequireRequestAuthMiddleware, FileUploadFormMiddleware);
this.delete('/:slug', FileController.deleteFileRoute, 'delete-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE); this.delete('/:slug', FileController.deleteFileRoute, 'delete-file', RequireRequestAuthMiddleware);
this.get('/:slug', this.getFile, 'get-file'); this.get('/:slug', this.getFile, 'get-file');
this.put('/:slug', this.putFile, 'put-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE); this.put('/:slug', this.putFile, 'put-file', RequireRequestAuthMiddleware, FileUploadFormMiddleware);
this.post('/', URLRedirectController.addURL, 'post-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE); this.post('/', URLRedirectController.addURL, 'post-url', RequireRequestAuthMiddleware);
this.delete('/:slug', this.deleteURL, 'delete-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE); this.delete('/:slug', this.deleteURL, 'delete-url', RequireRequestAuthMiddleware);
this.get('/:slug', this.getURLRedirect, 'get-url'); this.get('/:slug', this.getURLRedirect, 'get-url');
this.put('/:slug', URLRedirectController.addURL, 'put-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE); this.put('/:slug', URLRedirectController.addURL, 'put-url', RequireRequestAuthMiddleware);
this.get(/(.*)/, this.domainFilter); this.get(/(.*)/, this.domainFilter);
} }
protected async getFile(req: Request, res: Response, next: NextFunction): Promise<void> { protected async getFile(req: Request, res: Response, next: NextFunction): Promise<void> {
console.log('get file', req.params.slug)
const file = await FileModel.getBySlug(req.params.slug); const file = await FileModel.getBySlug(req.params.slug);
if (!file) return next(); if (!file) return next();
if (file.shouldBeDeleted()) { if (file.shouldBeDeleted()) {
@ -38,29 +36,31 @@ export default class LinkController extends Controller {
return next(); return next();
} }
switch (file.storage_type) { const fileName = file.getOrFail('real_name');
case 'local':
const stats = await promisify(fs.stat)(file.storage_path!);
// If file is bigger than max supported one shot reading, fallback to express download switch (file.storage_type) {
if (stats.size > buffer.constants.MAX_LENGTH) { case 'local': {
Logger.info(`Fallback to express download for file of size ${stats.size}`); const stats = await promisify(fs.stat)(file.getOrFail('storage_path'));
return res.download(file.storage_path!);
// If file is bigger than max hotlink size, fallback to express download
if (stats.size > config.get<number>('max_hotlink_size') * 1024 * 1024) {
log.info(`Fallback to express download for file of size ${stats.size}`);
return res.download(file.getOrFail('storage_path'), fileName);
} }
// File type // File type
const fileName = file.real_name!;
const parts = fileName.split('.'); const parts = fileName.split('.');
res.type(parts[parts.length - 1]); res.type(parts[parts.length - 1]);
// File name // File name
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeRFC5987ValueChars(fileName)}`); res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeRFC5987ValueChars(fileName)}`);
fs.readFile(file.storage_path!, (err, data) => { fs.readFile(file.getOrFail('storage_path'), (err, data) => {
if (err) return next(err); if (err) return next(err);
res.send(data); res.send(data);
}); });
break; break;
}
default: default:
throw new ServerError(`This file cannot be served. Download protocol for ${file.storage_type} storage type not implemented.`); throw new ServerError(`This file cannot be served. Download protocol for ${file.storage_type} storage type not implemented.`);
} }
@ -84,7 +84,7 @@ export default class LinkController extends Controller {
const url = await URLRedirect.getBySlug(req.params.slug); const url = await URLRedirect.getBySlug(req.params.slug);
if (!url) return next(); if (!url) return next();
res.redirect(url.target_url!, 301); res.redirect(url.getOrFail('target_url'), 301);
} }
protected async deleteURL(req: Request, res: Response, next: NextFunction): Promise<void> { protected async deleteURL(req: Request, res: Response, next: NextFunction): Promise<void> {
@ -94,7 +94,7 @@ export default class LinkController extends Controller {
throw new BadRequestError( throw new BadRequestError(
'Deleting url redirects is disabled for security reasons.', 'Deleting url redirects is disabled for security reasons.',
'If you still want to disable the redirection, please contact us via email.', 'If you still want to disable the redirection, please contact us via email.',
req.url req.url,
); );
} }

View File

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

View File

@ -1,22 +1,24 @@
import _MagicLinkController from "wms-core/auth/magic_link/MagicLinkController"; import _MagicLinkController from "swaf/auth/magic_link/MagicLinkController";
import {Request, Response} from "express"; import {Request, Response} from "express";
import Controller from "wms-core/Controller"; import Controller from "swaf/Controller";
import MagicLinkWebSocketListener from "wms-core/auth/magic_link/MagicLinkWebSocketListener"; import MagicLinkWebSocketListener from "swaf/auth/magic_link/MagicLinkWebSocketListener";
import MagicLink from "wms-core/auth/models/MagicLink"; import MagicLink from "swaf/auth/models/MagicLink";
import AuthController from "./AuthController"; import AuthController from "./AuthController";
import {MagicLinkActionType} from "./MagicLinkActionType"; import {MagicLinkActionType} from "./MagicLinkActionType";
import App from "../App";
import AuthComponent from "swaf/auth/AuthComponent";
export default class MagicLinkController extends _MagicLinkController { export default class MagicLinkController extends _MagicLinkController<App> {
constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener) { public constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener<App>) {
super(magicLinkWebSocketListener); super(magicLinkWebSocketListener);
} }
protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void> { protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void> {
switch (magicLink.getActionType()) { switch (magicLink.action_type) {
case MagicLinkActionType.LOGIN: case MagicLinkActionType.LOGIN:
case MagicLinkActionType.REGISTER: case MagicLinkActionType.REGISTER: {
await AuthController.checkAndAuth(req, res, magicLink); await AuthController.checkAndAuth(req, res, magicLink);
const proof = await req.authGuard.isAuthenticated(req.session!); const proof = await this.getApp().as(AuthComponent).getAuthGuard().isAuthenticated(req.getSession());
const user = await proof?.getResource(); const user = await proof?.getResource();
if (!res.headersSent && user) { if (!res.headersSent && user) {
@ -27,4 +29,5 @@ export default class MagicLinkController extends _MagicLinkController {
break; break;
} }
} }
}
} }

View File

@ -1,23 +1,24 @@
import Controller from "wms-core/Controller"; import Controller from "swaf/Controller";
import {NextFunction, Request, Response} from "express"; import {NextFunction, Request, Response} from "express";
import URLRedirect from "../models/URLRedirect"; import URLRedirect from "../models/URLRedirect";
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent"; import {RequireAuthMiddleware} from "swaf/auth/AuthComponent";
import generateSlug from "../SlugGenerator"; import generateSlug from "../SlugGenerator";
import config from "config"; import config from "config";
import AuthToken from "../models/AuthToken"; import AuthToken from "../models/AuthToken";
export default class URLRedirectController extends Controller { export default class URLRedirectController extends Controller {
routes(): void { public routes(): void {
this.get('/url/shrink', this.getURLShrinker, 'url-shrinker', REQUIRE_AUTH_MIDDLEWARE); this.get('/url/shrink', this.getURLShrinker, 'url-shrinker', RequireAuthMiddleware);
this.get('/url/shrink/script', this.downloadLinuxScript, 'url-linux-script'); this.get('/url/shrink/script', this.downloadLinuxScript, 'url-linux-script');
this.post('/url/shrink', this.addURLFrontend, 'shrink-url', REQUIRE_AUTH_MIDDLEWARE); this.post('/url/shrink', this.addURLFrontend, 'shrink-url', RequireAuthMiddleware);
this.get('/urls/:page([0-9]+)?', this.getURLRedirectManager, 'url-manager', REQUIRE_AUTH_MIDDLEWARE); this.get('/urls/:page([0-9]+)?', this.getURLRedirectManager, 'url-manager', RequireAuthMiddleware);
} }
protected async getURLShrinker(req: Request, res: Response): Promise<void> { protected async getURLShrinker(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const allowedDomains = config.get<string[]>('allowed_url_domains'); const allowedDomains = config.get<string[]>('allowed_url_domains');
res.render('url-shrinker', { res.render('url-shrinker', {
auth_tokens: await AuthToken.select().where('user_id', req.models.user!.id!).get(), auth_tokens: await AuthToken.select().where('user_id', user.id).get(),
allowed_domains: allowedDomains, allowed_domains: allowedDomains,
default_domain: allowedDomains[config.get<number>('default_url_domain_for_urls')], default_domain: allowedDomains[config.get<number>('default_url_domain_for_urls')],
}); });
@ -28,22 +29,31 @@ export default class URLRedirectController extends Controller {
} }
protected async getURLRedirectManager(req: Request, res: Response): Promise<void> { protected async getURLRedirectManager(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
res.render('url-manager', { res.render('url-manager', {
urls: await URLRedirect.paginateForUser(req, 100, req.models.user!.id!), urls: await URLRedirect.paginateForUser(req, 100, user.getOrFail('id')),
}); });
} }
protected async addURLFrontend(req: Request, res: Response, next: NextFunction): Promise<void> { protected async addURLFrontend(req: Request, res: Response, next: NextFunction): Promise<void> {
req.body.type = 'url'; req.body.type = 'url';
await URLRedirectController.addURL(req, res, next, req.body.autogen_url === undefined && req.body.slug ? req.body.slug : await generateSlug(10)); await URLRedirectController.addURL(
req,
res,
next,
req.body.autogen_url === undefined && req.body.slug ?
req.body.slug :
await generateSlug(10),
);
} }
public static async addURL(req: Request, res: Response, next: NextFunction, slug?: string): Promise<void> { public static async addURL(req: Request, res: Response, next: NextFunction, slug?: string): Promise<void> {
if (req.body.type !== 'url') return next(); if (req.body.type !== 'url') return next();
const user = req.as(RequireAuthMiddleware).getUser();
slug = slug || req.params.slug || req.body.slug || await generateSlug(10); slug = slug || req.params.slug || req.body.slug || await generateSlug(10);
const urlRedirect = URLRedirect.create({ const urlRedirect = URLRedirect.create({
user_id: req.models.user!.id, user_id: user.id,
slug: slug, slug: slug,
target_url: req.body.target_url, target_url: req.body.target_url,
}); });

View File

@ -1,10 +1,20 @@
import Logger from "wms-core/Logger"; import {delimiter} from "path";
// Load config from specified path or default + swaf/config (default defaults)
process.env['NODE_CONFIG_DIR'] =
__dirname + '/../../node_modules/swaf/config/'
+ delimiter
+ (process.env['NODE_CONFIG_DIR'] || __dirname + '/../../config/');
import {log} from "swaf/Logger";
import App from "./App"; import App from "./App";
import config from "config"; import config from "config";
(async () => { (async () => {
const app = new App(config.get<number>('port')); log.debug('Config path:', process.env['NODE_CONFIG_DIR']);
const app = new App(config.get<string>('listen_addr'), config.get<number>('port'));
await app.start(); await app.start();
})().catch(err => { })().catch(err => {
Logger.error(err); log.error(err);
}); });

View File

@ -1,6 +1,6 @@
import {Connection} from "mysql"; import {Connection} from "mysql";
import Migration from "wms-core/db/Migration"; import Migration from "swaf/db/Migration";
import ModelFactory from "wms-core/db/ModelFactory"; import ModelFactory from "swaf/db/ModelFactory";
import AuthToken from "../models/AuthToken"; import AuthToken from "../models/AuthToken";
export default class CreateAuthTokensTable extends Migration { export default class CreateAuthTokensTable extends Migration {

View File

@ -1,6 +1,6 @@
import {Connection} from "mysql"; import {Connection} from "mysql";
import Migration from "wms-core/db/Migration"; import Migration from "swaf/db/Migration";
import ModelFactory from "wms-core/db/ModelFactory"; import ModelFactory from "swaf/db/ModelFactory";
import FileModel from "../models/FileModel"; import FileModel from "../models/FileModel";
export default class CreateFilesTable extends Migration { export default class CreateFilesTable extends Migration {

View File

@ -1,6 +1,6 @@
import Migration from "wms-core/db/Migration"; import Migration from "swaf/db/Migration";
import {Connection} from "mysql"; import {Connection} from "mysql";
import ModelFactory from "wms-core/db/ModelFactory"; import ModelFactory from "swaf/db/ModelFactory";
import URLRedirect from "../models/URLRedirect"; import URLRedirect from "../models/URLRedirect";
export default class CreateUrlRedirectsTable extends Migration { export default class CreateUrlRedirectsTable extends Migration {

View File

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

View File

@ -1,41 +1,40 @@
import Model from "wms-core/db/Model"; import Model from "swaf/db/Model";
import AuthProof from "wms-core/auth/AuthProof"; import AuthProof from "swaf/auth/AuthProof";
import User from "wms-core/auth/models/User"; import User from "swaf/auth/models/User";
import {cryptoRandomDictionary} from "wms-core/Utils"; import {cryptoRandomDictionary} from "swaf/Utils";
export default class AuthToken extends Model implements AuthProof<User> { export default class AuthToken extends Model implements AuthProof<User> {
public id?: number = undefined; public id?: number = undefined;
protected readonly user_id?: number = undefined; protected readonly user_id?: number = undefined;
protected readonly secret?: string = undefined; private secret?: string = undefined;
protected created_at?: Date = undefined; protected created_at?: Date = undefined;
protected used_at?: Date = undefined; protected used_at?: Date = undefined;
protected readonly ttl?: number = undefined; protected readonly ttl?: number = undefined;
protected init() { protected init(): void {
this.setValidation('user_id').defined().exists(User, 'id'); this.setValidation('user_id').defined().exists(User, 'id');
this.setValidation('secret').defined().between(32, 64); this.setValidation('secret').defined().between(32, 64);
this.setValidation('ttl').defined().min(1).max(5 * 365 * 24 * 3600 /* 5 years */); this.setValidation('ttl').defined().min(1).max(5 * 365 * 24 * 3600 /* 5 years */);
} }
protected async autoFill(): Promise<void> { protected async autoFill(): Promise<void> {
await super.autoFill(); if (!this.secret) {
this.secret = cryptoRandomDictionary(64, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_');
// @ts-ignore }
if (!this.secret) this['secret'] = cryptoRandomDictionary(64, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_');
} }
public use() { public use(): void {
this.used_at = new Date(); this.used_at = new Date();
} }
public canDelete(user_id: number) { public canDelete(user_id: number): boolean {
return this.user_id === user_id; return this.user_id === user_id;
} }
public getExpirationDate(): Date { public getExpirationDate(): Date {
if (!this.created_at) return new Date(); if (!this.created_at) return new Date();
return new Date(this.created_at.getTime() + this.ttl! * 1000); return new Date(this.created_at.getTime() + this.getOrFail('ttl') * 1000);
} }
public async getResource(): Promise<User | null> { public async getResource(): Promise<User | null> {
@ -53,4 +52,8 @@ export default class AuthToken extends Model implements AuthProof<User> {
public async revoke(): Promise<void> { public async revoke(): Promise<void> {
await this.delete(); await this.delete();
} }
protected getSecret(): string | undefined {
return this.secret;
}
} }

View File

@ -1,7 +1,7 @@
import Model from "wms-core/db/Model"; import Model from "swaf/db/Model";
import Controller from "wms-core/Controller"; import Controller from "swaf/Controller";
import config from "config"; import config from "config";
import User from "wms-core/auth/models/User"; import User from "swaf/auth/models/User";
import {Request} from "express"; import {Request} from "express";
import URLRedirect from "./URLRedirect"; import URLRedirect from "./URLRedirect";
@ -28,7 +28,7 @@ export default class FileModel extends Model {
public created_at?: Date = undefined; public created_at?: Date = undefined;
public readonly ttl?: number = undefined; public readonly ttl?: number = undefined;
protected init() { protected init(): void {
this.setValidation('user_id').defined().exists(User, 'id'); this.setValidation('user_id').defined().exists(User, 'id');
this.setValidation('slug').defined().minLength(1).maxLength(259).unique(FileModel, 'slug').unique(URLRedirect, 'slug'); this.setValidation('slug').defined().minLength(1).maxLength(259).unique(FileModel, 'slug').unique(URLRedirect, 'slug');
this.setValidation('real_name').defined().minLength(1).maxLength(259); this.setValidation('real_name').defined().minLength(1).maxLength(259);
@ -40,7 +40,7 @@ export default class FileModel extends Model {
public getURL(domain: string = config.get<string>('base_url')): string { public getURL(domain: string = config.get<string>('base_url')): string {
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-file', { return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-file', {
slug: this.slug!, slug: this.getOrFail('slug'),
}); });
} }
@ -48,7 +48,7 @@ export default class FileModel extends Model {
if (!this.created_at) return new Date(); if (!this.created_at) return new Date();
if (this.ttl === 0) return null; if (this.ttl === 0) return null;
return new Date(this.created_at.getTime() + this.ttl! * 1000); return new Date(this.created_at.getTime() + this.getOrFail('ttl') * 1000);
} }
public shouldBeDeleted(): boolean { public shouldBeDeleted(): boolean {

View File

@ -1,9 +1,9 @@
import Model from "wms-core/db/Model"; import Model from "swaf/db/Model";
import User from "wms-core/auth/models/User"; import User from "swaf/auth/models/User";
import FileModel from "./FileModel"; import FileModel from "./FileModel";
import {Request} from "express"; import {Request} from "express";
import config from "config"; import config from "config";
import Controller from "wms-core/Controller"; import Controller from "swaf/Controller";
export default class URLRedirect extends Model { export default class URLRedirect extends Model {
public static get table(): string { public static get table(): string {
@ -32,7 +32,7 @@ export default class URLRedirect extends Model {
public getURL(domain: string = config.get<string>('base_url')): string { public getURL(domain: string = config.get<string>('base_url')): string {
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-url', { return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-url', {
slug: this.slug!, slug: this.getOrFail('slug'),
}); });
} }
} }

18
tsconfig.frontend.json Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "public/js",
"target": "ES6",
"strict": true,
"lib": [
"es2020",
"DOM"
],
"typeRoots": [
"./node_modules/@types"
]
},
"include": [
"assets/ts/**/*"
]
}

View File

@ -6,14 +6,17 @@
"target": "ES6", "target": "ES6",
"strict": true, "strict": true,
"lib": [ "lib": [
"es2020" "es2020",
"DOM"
], ],
"typeRoots": [ "typeRoots": [
"./node_modules/@types" "./node_modules/@types"
] ],
"resolveJsonModule": true,
"skipLibCheck": true
}, },
"include": [ "include": [
"src/**/*", "src/**/*",
"node_modules/wms-core" "node_modules/swaf/types"
] ]
} }

14
tsconfig.test.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"typeRoots": [
"node_modules/@types",
"src/types",
"test/types"
]
},
"include": [
"src/types/**/*",
"test/**/*"
]
}

View File

@ -8,7 +8,7 @@
<div class="container"> <div class="container">
<section class="panel"> <section class="panel">
<h2>Details</h2> <h2>Details</h2>
<p class="center">Powered by wms-core</p> <p class="center">Powered by swaf</p>
</section> </section>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -119,7 +119,7 @@
<section class="panel"> <section class="panel">
<h2>Auth tokens</h2> <h2>Auth tokens</h2>
<form action="{{ route('generate-token') }}" method="POST"> <form action="{{ route('generate-token') }}" method="POST">
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
<button type="submit"><i data-feather="plus"></i> Generate a new token</button> <button type="submit"><i data-feather="plus"></i> Generate a new token</button>
</form> </form>

View File

@ -43,7 +43,7 @@
Pending deletion Pending deletion
{% else %} {% else %}
<form action="{{ route('delete-file-frontend', file.slug) }}" method="post"> <form action="{{ route('delete-file-frontend', file.slug) }}" method="post">
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
<button class="button danger"><i data-feather="trash"></i> Delete</button> <button class="button danger"><i data-feather="trash"></i> Delete</button>
</form> </form>
{% endif %} {% endif %}

View File

@ -24,7 +24,7 @@
{{ macros.field(_locals, 'text', 'slug', '', 'Custom url slug', 'Example: beautiful_image.jpg sets url to https://'+default_domain+'/beautiful_image.jpg', validation_attributes='disabled') }} {{ macros.field(_locals, 'text', 'slug', '', 'Custom url slug', 'Example: beautiful_image.jpg sets url to https://'+default_domain+'/beautiful_image.jpg', validation_attributes='disabled') }}
{{ macros.field(_locals, 'checkbox', 'autogen_url', '', 'Generate url automatically', null, validation_attributes='checked') }} {{ macros.field(_locals, 'checkbox', 'autogen_url', '', 'Generate url automatically', null, validation_attributes='checked') }}
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
<button type="submit"><i data-feather="upload"></i> Upload</button> <button type="submit"><i data-feather="upload"></i> Upload</button>
</form> </form>

View File

@ -29,7 +29,7 @@
<form action="{{ route('logout') }}?{{ querystring.stringify({redirect_uri: '/'}) }}" method="POST"> <form action="{{ route('logout') }}?{{ querystring.stringify({redirect_uri: '/'}) }}" method="POST">
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button> <button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
</form> </form>
</li> </li>
{% else %} {% else %}

View File

@ -20,7 +20,7 @@
{{ macros.field(_locals, 'text', 'slug', '', 'Custom url slug', 'Example: bear sets url to https://'+default_domain+'/bear', validation_attributes='disabled') }} {{ macros.field(_locals, 'text', 'slug', '', 'Custom url slug', 'Example: bear sets url to https://'+default_domain+'/bear', validation_attributes='disabled') }}
{{ macros.field(_locals, 'checkbox', 'autogen_url', '', 'Generate url automatically', null, validation_attributes='checked') }} {{ macros.field(_locals, 'checkbox', 'autogen_url', '', 'Generate url automatically', null, validation_attributes='checked') }}
{{ macros.csrf(getCSRFToken) }} {{ macros.csrf(getCsrfToken) }}
<button type="submit"><i data-feather="link"></i> Shrink URL</button> <button type="submit"><i data-feather="link"></i> Shrink URL</button>
</form> </form>

View File

@ -1,6 +1,6 @@
const path = require('path'); const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin');
const dev = process.env.NODE_ENV === 'development'; const dev = process.env.NODE_ENV === 'development';
@ -48,6 +48,16 @@ const config = {
test: /\.(woff2?|eot|ttf|otf)$/i, test: /\.(woff2?|eot|ttf|otf)$/i,
use: 'file-loader?name=../fonts/[name].[ext]', use: 'file-loader?name=../fonts/[name].[ext]',
}, },
{
test: /\.tsx?$/i,
use: {
loader: 'ts-loader',
options: {
configFile: 'tsconfig.frontend.json',
}
},
exclude: '/node_modules/'
},
{ {
test: /\.(png|jpe?g|gif|svg)$/i, test: /\.(png|jpe?g|gif|svg)$/i,
use: [ use: [
@ -68,6 +78,9 @@ const config = {
} }
], ],
}, },
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [ plugins: [
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: '../css/[name].css', filename: '../css/[name].css',
@ -77,8 +90,9 @@ const config = {
if (!dev) { if (!dev) {
config.optimization = { config.optimization = {
minimize: true,
minimizer: [ minimizer: [
new UglifyJSPlugin(), new TerserPlugin(),
] ]
}; };
} }

5014
yarn.lock

File diff suppressed because it is too large Load Diff