Compare commits

..

1 Commits

Author SHA1 Message Date
Alice Gaudon c3031d8171 Rename project to swaf 2020-11-12 16:26:47 +01:00
222 changed files with 5839 additions and 15546 deletions

View File

@ -1,135 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'svelte3',
'@typescript-eslint',
'import',
'simple-import-sort',
],
parserOptions: {
tsconfigRootDir: __dirname,
project: [
'./tsconfig.test.json',
'./src/tsconfig.json',
'./src/common/tsconfig.json',
'./src/assets/ts/tsconfig.eslint.json',
'./src/assets/views/tsconfig.json',
]
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
indent: [
'error',
4,
{
SwitchCase: 1
}
],
'no-trailing-spaces': 'error',
'max-len': [
'error',
{
code: 120,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true
}
],
semi: 'off',
'@typescript-eslint/semi': [
'error'
],
'no-extra-semi': 'error',
'eol-last': 'error',
'comma-dangle': 'off',
'simple-import-sort/imports': 'error',
'no-extra-parens': 'off',
'no-nested-ternary': 'error',
'no-return-await': 'off',
'no-useless-return': 'error',
'no-useless-constructor': 'off',
'import/extensions': ['error', 'ignorePackages'],
'@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'
}
],
'@typescript-eslint/no-extra-parens': [
'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',
'@typescript-eslint/no-useless-constructor': [
'error'
],
'@typescript-eslint/return-await': [
'error',
'always'
],
'@typescript-eslint/explicit-member-accessibility': [
'error',
{
accessibility: 'explicit'
}
],
'@typescript-eslint/no-floating-promises': 'error',
},
ignorePatterns: [
'.eslintrc.js',
'rollup.config.js',
'jest.config.js',
'dist/**/*',
'config/**/*',
'intermediates/**/*',
'public/**/*',
'scripts/**/*',
'src/frontend/register_svelte/register_svelte.js',
],
overrides: [
{
files: [
'test/**/*'
],
rules: {
'max-len': [
'error',
{
code: 120,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true,
ignoreStrings: true
}
]
}
},
{
files: ['*.svelte'],
processor: 'svelte3/svelte3'
}
],
settings: {
'svelte3/typescript': require('typescript'),
'svelte3/ignore-styles': function (attributes) {
return !!(attributes['lang'] && attributes['lang'] !== 'css');
}
},
}

110
.eslintrc.json Normal file
View File

@ -0,0 +1,110 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"parserOptions": {
"project": [
"./tsconfig.json",
"./tsconfig.test.json"
]
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-trailing-spaces": "error",
"max-len": [
"error",
{
"code": 120,
"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",
"dist/**/*",
"config/**/*"
],
"overrides": [
{
"files": [
"test/**/*"
],
"rules": {
"max-len": [
"error",
{
"code": 120,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true,
"ignoreStrings": true
}
]
}
}
]
}

6
.gitignore vendored
View File

@ -1,10 +1,4 @@
.idea
node_modules
dist
intermediates
public
yarn-error.log
config/local.*
tsconfig.tsbuildinfo

View File

@ -1,28 +1,37 @@
{
asset_cache: false,
gitlab_webhook_token: 'default',
app: {
listen_addr: '127.0.0.1',
port: 4899,
public_url: "http://127.0.0.1:4899",
public_websocket_url: "ws://127.0.0.1:4899",
name: 'Example App',
contact_email: 'contact@example.net',
display_email_warning: true,
},
auth: {
// Registered accounts need to be approved by an administrator
approval_mode: false,
// 30 days
name_change_wait_period: 2592000000,
contact_email: 'contact@example.net'
},
log: {
level: "DEBUG",
verbose: true,
db_level: "ERROR",
},
magic_link: {
validity_period: 20,
base_url: "http://localhost:4899",
public_websocket_url: "ws://localhost:4899",
listen_addr: '127.0.0.1',
port: 4899,
gitlab_webhook_token: 'default',
mysql: {
connectionLimit: 10,
host: "localhost",
user: "root",
password: "",
database: "swaf",
create_database_automatically: false
},
redis: {
host: "127.0.0.1",
port: 6379,
prefix: 'swaf'
},
session: {
secret: 'default',
cookie: {
secure: false,
maxAge: 2592000000, // 30 days
}
},
mail: {
host: "127.0.0.1",
@ -34,29 +43,8 @@
from: 'contact@example.net',
from_name: 'Example App',
},
mysql: {
connectionLimit: 10,
host: "127.0.0.1",
user: "root",
password: "",
database: "swaf",
create_database_automatically: false,
},
redis: {
host: "127.0.0.1",
port: 6379,
prefix: 'swaf',
},
session: {
secret: 'default',
cookie: {
secure: false,
// 1 year
maxAge: 31557600000,
},
},
view: {
cache: false,
dev: true,
cache: false
},
approval_mode: false,
}

View File

@ -1,28 +1,18 @@
{
asset_cache: true,
app: {
public_url: "https://swaf.example",
public_websocket_url: "wss://swaf.example",
},
log: {
level: "DEV",
verbose: false,
db_level: "ERROR",
},
magic_link: {
validity_period: 900,
base_url: "https://watch-my.stream",
public_websocket_url: "wss://watch-my.stream",
session: {
cookie: {
secure: true
}
},
mail: {
secure: true,
allow_invalid_tls: false,
},
session: {
cookie: {
secure: true,
},
},
view: {
cache: true,
dev: false,
allow_invalid_tls: false
}
}

View File

@ -1,18 +1,9 @@
{
auth: {
approval_mode: true,
},
mysql: {
host: "127.0.0.1",
host: "localhost",
user: "root",
password: "",
database: "swaf_test",
create_database_automatically: true,
},
session: {
cookie: {
// 1s
maxAge: 1000,
},
},
create_database_automatically: true
}
}

View File

@ -16,5 +16,4 @@ module.exports = {
'**/test/**/*.test.ts'
],
testEnvironment: 'node',
resolver: "jest-ts-webcompat-resolver",
};
};

View File

@ -1,108 +1,76 @@
{
"name": "swaf",
"version": "0.25.1",
"description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/ashpie/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>",
"license": "MIT",
"readme": "README.md",
"publishConfig": {
"registry": "https://registry.npmjs.com",
"access": "public"
},
"main": "dist/main.js",
"types": "dist/index.d.ts",
"scripts": {
"test": "jest --verbose --runInBand",
"clean": "node scripts/clean.js",
"prepare-sources": "node scripts/prepare-sources.js",
"compile": "yarn clean && yarn prepare-sources && tsc --build",
"build": "yarn compile && node . pre-compile-views && node scripts/dist.js",
"build-production": "NODE_ENV=production yarn build",
"dev": "yarn compile && concurrently -k -n \"Maildev,Typescript,ViewPreCompile,Node\" -p \"[{name}]\" -c \"yellow,blue,red,green\" \"maildev --ip 127.0.0.1\" \"tsc --build --watch --preserveWatchOutput\" \"nodemon -i public -i intermediates -- pre-compile-views --watch\" \"nodemon -i public -i intermediates\"",
"lint": "eslint .",
"release": "yarn build && yarn lint && yarn test && cd dist && yarn publish"
},
"devDependencies": {
"@sveltejs/eslint-config": "sveltejs/eslint-config",
"@tsconfig/svelte": "^3.0.0",
"@types/compression": "^1.7.0",
"@types/config": "^0.0.41",
"@types/connect-flash": "^0.0.37",
"@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/express-session": "^1.17.0",
"@types/formidable": "^2.0.0",
"@types/geoip-lite": "^1.1.31",
"@types/jest": "^27.0.2",
"@types/mjml": "^4.0.4",
"@types/mysql": "^2.15.10",
"@types/node": "^17.0.21",
"@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3",
"@types/on-finished": "^2.3.1",
"@types/require-from-string": "^1.2.0",
"@types/supertest": "^2.0.10",
"@types/uuid": "^8.0.0",
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^5.3.0",
"@typescript-eslint/parser": "^5.3.0",
"chokidar": "^3.5.1",
"concurrently": "^7.0.0",
"eslint": "^8.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-svelte3": "^3.1.2",
"jest": "^27.3.1",
"jest-resolve": "^27.3.1",
"jest-ts-webcompat-resolver": "^1.0.0",
"maildev": "^1.1.0",
"node-fetch": "^3.0.0",
"nodemon": "^2.0.6",
"sass": "^1.32.12",
"supertest": "^6.0.0",
"svelte-check": "^2.2.8",
"ts-jest": "^27.0.7",
"typescript": "^4.0.2"
},
"dependencies": {
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^13.0.6",
"@rollup/plugin-url": "^6.1.0",
"argon2": "^0.28.2",
"clear-module": "^4.1.1",
"compression": "^1.7.4",
"config": "^3.3.1",
"connect-flash": "^0.1.1",
"cookie": "^0.4.1",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-session": "^1.17.1",
"formidable": "^2.0.1",
"geoip-lite": "^1.4.2",
"lucide": "^0.17.7",
"mjml": "^4.6.2",
"mysql": "^2.18.1",
"nanoid": "^3.1.20",
"nodemailer": "^6.4.6",
"normalize.css": "^8.0.1",
"nunjucks": "^3.2.1",
"on-finished": "^2.3.0",
"redis": "^4.0.4",
"require-from-string": "^2.0.2",
"rollup": "^2.42.3",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-imagemin": "^0.4.1",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"svelte": "^3.35.0",
"svelte-preprocess": "^4.9.8",
"ts-node": "^10.4.0",
"tslog": "^3.0.1",
"uuid": "^8.0.0",
"ws": "^8.2.3"
}
"name": "swaf",
"version": "0.22.5",
"description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/arisu/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>",
"license": "MIT",
"readme": "README.md",
"publishConfig": {
"registry": "https://registry.npmjs.com",
"access": "public"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"test": "jest --verbose --runInBand",
"build": "(test ! -d dist || rm -r dist) && tsc && cp package.json dist/ && cp yarn.lock dist/ && cp -r config dist/ && cp -r views dist/ && mkdir dist/types && cp src/types/* dist/types/",
"release": "yarn lint && yarn test && yarn build && cd dist && yarn publish",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"devDependencies": {
"@types/compression": "^1.7.0",
"@types/config": "^0.0.36",
"@types/connect-flash": "^0.0.35",
"@types/connect-redis": "^0.0.14",
"@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/express-session": "^1.17.0",
"@types/formidable": "^1.0.31",
"@types/geoip-lite": "^1.1.31",
"@types/jest": "^26.0.4",
"@types/mjml": "^4.0.4",
"@types/mysql": "^2.15.10",
"@types/node-fetch": "^2.5.7",
"@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3",
"@types/on-finished": "^2.3.1",
"@types/redis": "^2.8.18",
"@types/supertest": "^2.0.10",
"@types/uuid": "^8.0.0",
"@types/ws": "^7.2.4",
"@typescript-eslint/eslint-plugin": "^4.2.0",
"@typescript-eslint/parser": "^4.2.0",
"eslint": "^7.9.0",
"jest": "^26.1.0",
"maildev": "^1.1.0",
"node-fetch": "^2.6.0",
"supertest": "^6.0.0",
"ts-jest": "^26.1.1",
"typescript": "^4.0.2"
},
"dependencies": {
"argon2": "^0.27.0",
"compression": "^1.7.4",
"config": "^3.3.1",
"connect-flash": "^0.1.1",
"connect-redis": "^5.0.0",
"cookie": "^0.4.1",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-session": "^1.17.1",
"formidable": "^1.2.2",
"geoip-lite": "^1.4.2",
"mjml": "^4.6.2",
"mysql": "^2.18.1",
"nodemailer": "^6.4.6",
"nunjucks": "^3.2.1",
"on-finished": "^2.3.0",
"redis": "^3.0.2",
"ts-node": "^9.0.0",
"tslog": "^2.10.0",
"uuid": "^8.0.0",
"ws": "^7.2.3"
}
}

View File

@ -1,74 +0,0 @@
import path from "path";
import svelte from "rollup-plugin-svelte";
import cssOnlyRollupPlugin from "rollup-plugin-css-only";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import {terser} from "rollup-plugin-terser";
import livereloadRollupPlugin from "rollup-plugin-livereload";
import imageminPlugin from "rollup-plugin-imagemin";
import url from "@rollup/plugin-url";
const production = process.env.ENV === 'production';
const buildDir = process.env.BUILD_DIR;
const publicDir = process.env.PUBLIC_DIR;
const input = process.env.INPUT.split(':');
export default commandLineArgs => ({
input: input,
output: {
format: 'es',
sourcemap: true,
dir: path.join(publicDir, 'js'),
entryFileNames: (chunkInfo) => {
const name = chunkInfo.facadeModuleId ?
path.relative(buildDir, chunkInfo.facadeModuleId) :
chunkInfo.name;
return name + '.js';
},
chunkFileNames: '[name].js',
},
plugins: [
imageminPlugin({
fileName: '../img/[name][extname]'
}),
svelte({
compilerOptions: {
dev: !production,
hydratable: true,
},
}),
// Extract css into separate files
cssOnlyRollupPlugin({output: 'bundle.css'}),
url({
include: [
'**/*.woff2?',
'**/*.ttf',
],
limit: 0,
fileName: path.join('../', 'fonts', '[name][extname]'),
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte'],
}),
commonjs(),
// Live reload in dev
!production && !!commandLineArgs.watch && livereloadRollupPlugin(publicDir),
// Minify in production
production && terser(),
],
watch: {
clearScreen: false,
},
});

View File

@ -1,22 +0,0 @@
const fs = require('fs');
const path = require('path');
function copyRecursively(file, destination) {
const target = path.join(destination, path.basename(file));
if (fs.statSync(file).isDirectory()) {
console.log('mkdir', target);
fs.mkdirSync(target, {recursive: true});
fs.readdirSync(file).forEach(f => {
copyRecursively(path.join(file, f), target);
});
} else {
console.log('> cp ', target);
fs.copyFileSync(file, target);
}
}
module.exports = {
copyRecursively,
};

View File

@ -1,12 +0,0 @@
const fs = require('fs');
[
'intermediates',
'dist',
'public',
].forEach(file => {
if (fs.existsSync(file)) {
console.log('Cleaning', file, '...');
fs.rmSync(file, {recursive: true});
}
});

View File

@ -1,23 +0,0 @@
const fs = require('fs');
const path = require('path');
const {copyRecursively} = require('./_functions.js');
[
'yarn.lock',
'README.md',
'config/',
'rollup.config.js',
].forEach(file => {
copyRecursively(file, 'dist');
});
fs.mkdirSync('dist/types', {recursive: true});
fs.readdirSync('src/types').forEach(file => {
copyRecursively(path.join('src/types', file), 'dist/types');
});
fs.readdirSync('src/assets').forEach(file => {
copyRecursively(path.join('src/assets', file), 'dist/assets');
});

View File

@ -1,21 +0,0 @@
const fs = require('fs');
const path = require('path');
// These folders must exist for nodemon not to loop indefinitely.
[
'public',
'dist',
'intermediates',
'intermediates/assets',
].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
});
// Symlink to build/common
const symlink = path.resolve('intermediates/common');
if (!fs.existsSync(symlink)) {
fs.symlinkSync(path.resolve('dist/common'), symlink);
}
// Copy package.json
fs.copyFileSync('package.json', 'dist/package.json');

View File

@ -1,32 +1,26 @@
import config from "config";
import express, {NextFunction, Request, Response, Router} from 'express';
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError";
import {lib} from "nunjucks";
import WebSocketListener from "./WebSocketListener";
import ApplicationComponent from "./ApplicationComponent";
import Controller from "./Controller";
import MysqlConnectionManager from "./db/MysqlConnectionManager";
import Migration, {MigrationType} from "./db/Migration";
import {Type} from "./Utils";
import LogRequestsComponent from "./components/LogRequestsComponent";
import {ValidationBag} from "./db/Validator";
import config from "config";
import * as fs from "fs";
import nunjucks from "nunjucks";
import SecurityError from "./SecurityError";
import * as path from "path";
import ApplicationComponent from "./ApplicationComponent.js";
import CacheProvider from "./CacheProvider.js";
import {route, setPublicUrl} from "./common/Routing.js";
import FrontendToolsComponent from "./components/FrontendToolsComponent.js";
import LogRequestsComponent from "./components/LogRequestsComponent.js";
import RedisComponent from "./components/RedisComponent.js";
import Controller from "./Controller.js";
import Migration, {MigrationType} from "./db/Migration.js";
import MysqlConnectionManager from "./db/MysqlConnectionManager.js";
import {ValidationBag, ValidationError} from "./db/Validator.js";
import Extendable, {MissingComponentError} from "./Extendable.js";
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError.js";
import {logger, loggingContextMiddleware} from "./Logger.js";
import SecurityError from "./SecurityError.js";
import {doesFileExist, Type} from "./Utils.js";
import WebSocketListener from "./WebSocketListener.js";
import TemplateError = nunjucks.lib.TemplateError;
import AppLocalsCoreComponents from "./components/core/AppLocalsCoreComponents.js";
import LazyLocalsCoreComponent from "./components/core/LazyLocalsCoreComponent.js";
import CacheProvider from "./CacheProvider";
import RedisComponent from "./components/RedisComponent";
import Extendable from "./Extendable";
import {log} from "./Logger";
import TemplateError = lib.TemplateError;
export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
private readonly version: string;
private coreVersion: string = 'unknown';
private readonly ignoreCommandLine: boolean;
private readonly controllers: Controller[] = [];
private readonly webSocketListeners: { [p: string]: WebSocketListener<Application> } = {};
@ -34,19 +28,15 @@ export default abstract class Application implements Extendable<ApplicationCompo
private cacheProvider?: CacheProvider;
private ready: boolean = false;
private started: boolean = false;
private busy: boolean = false;
protected constructor(version: string, ignoreCommandLine: boolean = false) {
this.version = version;
this.ignoreCommandLine = ignoreCommandLine;
setPublicUrl(config.get<string>('app.public_url'));
}
protected abstract getMigrations(): MigrationType<Migration>[];
protected abstract init(): Promise<void>;
protected abstract async init(): Promise<void>;
protected use(thing: Controller | WebSocketListener<this> | ApplicationComponent): void {
if (thing instanceof Controller) {
@ -56,7 +46,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
const path = thing.path();
this.webSocketListeners[path] = thing;
thing.init(this);
logger.info(`Added websocket listener on ${path}`);
log.info(`Added websocket listener on ${path}`);
} else {
thing.setApp(this);
this.components.push(thing);
@ -68,62 +58,23 @@ export default abstract class Application implements Extendable<ApplicationCompo
}
public async start(): Promise<void> {
if (this.started) throw new Error('Application already started');
if (this.busy) throw new Error('Application busy');
this.busy = true;
// Load core version
const file = await this.isInNodeModules() ?
'node_modules/swaf/package.json' :
'package.json';
try {
this.coreVersion = JSON.parse(fs.readFileSync(file).toString()).version;
} catch (e) {
logger.warn('Couldn\'t determine coreVersion.', e);
}
logger.info(`${config.get('app.name')} v${this.version} | swaf v${this.coreVersion}`);
// Catch interrupt signals
const exitHandler = () => {
log.info(`${config.get('app.name')} v${this.version} - hi`);
process.once('SIGINT', () => {
this.stop().catch(console.error);
};
process.once('exit', exitHandler);
process.once('SIGINT', exitHandler);
process.once('SIGUSR1', exitHandler);
process.once('SIGUSR2', exitHandler);
process.once('SIGTERM', exitHandler);
process.once('uncaughtException', exitHandler);
});
// Register migrations
MysqlConnectionManager.registerMigrations(this.getMigrations());
// Register and initialize all components and alike
this.use(new AppLocalsCoreComponents());
this.use(new LazyLocalsCoreComponent());
await this.init();
for (const component of this.components) {
await component.init?.();
}
// Process command line
if (!this.ignoreCommandLine) {
let result: boolean;
try {
result = await this.processCommandLine();
} catch (err) {
logger.error(err);
process.exit(1);
}
if (result) {
this.started = true;
this.busy = false;
return;
}
if (!this.ignoreCommandLine && await this.processCommandLine()) {
await this.stop();
return;
}
// Register all components and alike
await this.init();
// Security
if (process.env.NODE_ENV === 'production') {
await this.checkSecuritySettings();
@ -131,11 +82,6 @@ export default abstract class Application implements Extendable<ApplicationCompo
// Init express
const app = express();
// Logging context
app.use(loggingContextMiddleware);
// Routers
const initRouter = express.Router();
const handleRouter = express.Router();
app.use(initRouter);
@ -145,32 +91,24 @@ export default abstract class Application implements Extendable<ApplicationCompo
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) return next(err);
// Transform single validation errors into a validation bag for convenience
if (err instanceof ValidationError) {
const bag = new ValidationBag();
bag.addMessage(err);
err = bag;
}
if (err instanceof ValidationBag) {
const bag = err;
res.format({
json: () => {
res.status(400);
res.status(401);
res.json({
status: 'error',
code: 400,
code: 401,
message: 'Invalid form data',
messages: bag.getMessages(),
messages: err.getMessages(),
});
},
text: () => {
res.status(400);
res.send('Error: ' + bag.getMessages());
res.status(401);
res.send('Error: ' + err.getMessages());
},
html: () => {
req.flash('validation', bag.getMessages());
res.redirect(req.getPreviousUrl() || route('home'));
req.flash('validation', err.getMessages());
res.redirectBack();
},
});
return;
@ -192,18 +130,11 @@ export default abstract class Application implements Extendable<ApplicationCompo
res.status(httpError.errorCode);
res.format({
html: () => {
const locals = {
res.render('errors/' + httpError.errorCode + '.njk', {
error_code: httpError.errorCode,
error_message: httpError.message,
error_instructions: httpError.instructions,
error_id: errorId,
};
res.formatViewData('errors/' + httpError.errorCode, locals, (err: Error | undefined, html) => {
if (err) {
res.formatViewData('templates/ErrorTemplate', locals);
} else {
res.send(html);
}
});
},
json: () => {
@ -221,102 +152,50 @@ export default abstract class Application implements Extendable<ApplicationCompo
});
});
// Components routes
for (const component of this.components) {
if (component.initRoutes) {
component.setCurrentRouter(initRouter);
await component.initRoutes(initRouter);
}
if (component.handleRoutes) {
component.setCurrentRouter(handleRouter);
await component.handleRoutes(handleRouter);
}
component.setCurrentRouter(null);
}
// Start components
for (const component of this.components) {
await component.start?.(app);
}
// Components routes
for (const component of this.components) {
if (component.init) {
component.setCurrentRouter(initRouter);
await component.init(initRouter);
}
if (component.handle) {
component.setCurrentRouter(handleRouter);
await component.handle(handleRouter);
}
component.setCurrentRouter(null);
}
// Routes
this.routes(initRouter, handleRouter);
this.ready = true;
this.started = true;
this.busy = false;
}
protected async processCommandLine(): Promise<boolean> {
const args = process.argv;
// Flags
const flags = {
verbose: false,
fullHttpRequests: false,
watch: false,
};
let mainCommand: string | null = null;
const mainCommandArgs: string[] = [];
for (let i = 2; i < args.length; i++) {
switch (args[i]) {
case '--verbose':
flags.verbose = true;
log.setSettings({minLevel: "trace"});
break;
case '--full-http-requests':
flags.fullHttpRequests = true;
break;
case '--watch':
flags.watch = true;
LogRequestsComponent.logFullHttpRequests();
break;
case 'migration':
case 'pre-compile-views':
if (mainCommand === null) mainCommand = args[i];
else throw new Error(`Only one main command can be used at once (${mainCommand},${args[i]})`);
break;
await MysqlConnectionManager.migrationCommand(args.slice(i + 1));
return true;
default:
if (mainCommand) {
mainCommandArgs.push(args[i]);
} else {
logger.fatal('Unrecognized argument', args[i]);
return true;
}
break;
log.warn('Unrecognized argument', args[i]);
return true;
}
}
if (flags.verbose) logger.setSettings({minLevel: "trace"});
if (flags.fullHttpRequests) LogRequestsComponent.logFullHttpRequests();
if (mainCommand) {
switch (mainCommand) {
case 'migration':
await MysqlConnectionManager.migrationCommand(mainCommandArgs);
await this.stop();
break;
case 'pre-compile-views': {
// Prepare migrations
for (const migration of this.getMigrations()) {
new migration().registerModels?.();
}
// Prepare routes
for (const controller of this.controllers) {
controller.setupRoutes();
}
const frontendToolsComponent = this.as(FrontendToolsComponent);
await frontendToolsComponent.preCompileViews(flags.watch);
break;
}
default:
logger.fatal('Unimplemented main command', mainCommand);
break;
}
return true;
}
return false;
}
@ -341,18 +220,13 @@ export default abstract class Application implements Extendable<ApplicationCompo
}
public async stop(): Promise<void> {
if (this.started && !this.busy) {
this.busy = true;
logger.info('Stopping application...');
log.info('Stopping application...');
for (const component of this.components) {
await component.stop?.();
}
logger.info(`${this.constructor.name} stopped properly.`);
this.started = false;
this.busy = false;
for (const component of this.components) {
await component.stop?.();
}
log.info(`${this.constructor.name} v${this.version} - bye`);
}
private routes(initRouter: Router, handleRouter: Router) {
@ -360,7 +234,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
if (controller.hasGlobalMiddlewares()) {
controller.setupGlobalHandlers(handleRouter);
logger.info(`Registered global middlewares for controller ${controller.constructor.name}`);
log.info(`Registered global middlewares for controller ${controller.constructor.name}`);
}
}
@ -369,7 +243,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
initRouter.use(controller.getRoutesPrefix(), fileUploadFormRouter);
handleRouter.use(controller.getRoutesPrefix(), mainRouter);
logger.info(`> Registered routes for controller ${controller.constructor.name} at ${controller.getRoutesPrefix()}`);
log.info(`> Registered routes for controller ${controller.constructor.name}`);
}
handleRouter.use((req: Request) => {
@ -377,6 +251,14 @@ export default abstract class Application implements Extendable<ApplicationCompo
});
}
public isReady(): boolean {
return this.ready;
}
public getVersion(): string {
return this.version;
}
public getWebSocketListeners(): { [p: string]: WebSocketListener<Application> } {
return this.webSocketListeners;
}
@ -385,10 +267,6 @@ export default abstract class Application implements Extendable<ApplicationCompo
return this.cacheProvider || null;
}
public getComponents(): ApplicationComponent[] {
return [...this.components];
}
public as<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): C {
const module = this.components.find(component => component.constructor === type) ||
Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
@ -401,30 +279,4 @@ export default abstract class Application implements Extendable<ApplicationCompo
Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
return module ? module as C : null;
}
public has<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): boolean {
return !!this.asOptional(type);
}
public require<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): void {
if (!this.has(type)) {
throw new MissingComponentError(type);
}
}
public async isInNodeModules(): Promise<boolean> {
return await doesFileExist('node_modules/swaf');
}
public isReady(): boolean {
return this.ready;
}
public getVersion(): string {
return this.version;
}
public getCoreVersion(): string {
return this.coreVersion;
}
}

View File

@ -1,11 +1,10 @@
import config from "config";
import {Express, Router} from "express";
import Application from "./Application.js";
import {logger} from "./Logger.js";
import Middleware, {MiddlewareType} from "./Middleware.js";
import SecurityError from "./SecurityError.js";
import {sleep} from "./Utils.js";
import {log} from "./Logger";
import {sleep} from "./Utils";
import Application from "./Application";
import config from "config";
import SecurityError from "./SecurityError";
import Middleware, {MiddlewareType} from "./Middleware";
export default abstract class ApplicationComponent {
private currentRouter?: Router;
@ -13,19 +12,13 @@ export default abstract class ApplicationComponent {
public async checkSecuritySettings?(): Promise<void>;
public async init?(): Promise<void>;
public async initRoutes?(router: Router): Promise<void>;
public async handleRoutes?(router: Router): Promise<void>;
public async start?(expressApp: Express): Promise<void>;
public async stop?(): Promise<void>;
public async init?(router: Router): Promise<void>;
public isReady(): boolean {
return true;
}
public async handle?(router: Router): Promise<void>;
public async stop?(): Promise<void>;
protected async prepare(name: string, prepare: () => Promise<void>): Promise<void> {
let err;
@ -35,23 +28,23 @@ export default abstract class ApplicationComponent {
err = null;
} catch (e) {
err = e;
logger.error(err, `${name} failed to prepare; retrying in 5s...`);
log.error(err, `${name} failed to prepare; retrying in 5s...`);
await sleep(5000);
}
} while (err);
logger.info(`${name} ready!`);
log.info(`${name} ready!`);
}
protected async close(thingName: string, fn: (callback: (err?: Error | null) => void) => void): Promise<void> {
try {
await new Promise<void>((resolve, reject) => fn((err?: Error | null) => {
await new Promise((resolve, reject) => fn((err?: Error | null) => {
if (err) reject(err);
else resolve();
}));
logger.info(`${thingName} closed.`);
log.info(`${thingName} closed.`);
} catch (e) {
logger.error(e, `An error occurred while closing the ${thingName}.`);
log.error(e, `An error occurred while closing the ${thingName}.`);
}
}

View File

@ -1,13 +1,47 @@
import express, {IRouter, RequestHandler, Router} from "express";
import {PathParams} from "express-serve-static-core";
import Application from "./Application.js";
import {registerRoute} from "./common/Routing.js";
import FileUploadMiddleware from "./FileUploadMiddleware.js";
import {logger} from "./Logger.js";
import Middleware, {MiddlewareType} from "./Middleware.js";
import config from "config";
import {log} from "./Logger";
import Validator, {ValidationBag} from "./db/Validator";
import FileUploadMiddleware from "./FileUploadMiddleware";
import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring";
import Middleware, {MiddlewareType} from "./Middleware";
import Application from "./Application";
export default abstract class Controller {
private static readonly routes: { [p: string]: string | undefined } = {};
public static route(
route: string,
params: RouteParams = [],
query: ParsedUrlQueryInput = {},
absolute: boolean = false,
): string {
let path = this.routes[route];
if (path === undefined) throw new Error(`Unknown route for name ${route}.`);
if (typeof params === 'string' || typeof params === 'number') {
path = path.replace(/:[a-zA-Z_-]+\??/g, '' + params);
} else if (Array.isArray(params)) {
let i = 0;
for (const match of path.matchAll(/:[a-zA-Z_-]+(\(.*\))?\??/g)) {
if (match.length > 0) {
path = path.replace(match[0], typeof params[i] !== 'undefined' ? params[i] : '');
}
i++;
}
path = path.replace(/\/+/g, '/');
} else {
for (const key of Object.keys(params)) {
path = path.replace(new RegExp(`:${key}\\??`), params[key]);
}
}
const queryStr = querystring.stringify(query);
return `${absolute ? config.get<string>('base_url') : ''}${path}` + (queryStr.length > 0 ? '?' + queryStr : '');
}
private readonly router: Router = express.Router();
private readonly fileUploadFormRouter: Router = express.Router();
private app?: Application;
@ -41,21 +75,7 @@ export default abstract class Controller {
}
protected use(handler: RequestHandler): void {
this.router.use(this.wrap(handler));
logger.info('Installed anonymous middleware on ' + this.getRoutesPrefix());
}
protected useMiddleware(...middlewares: MiddlewareType<Middleware>[]): void {
for (const middleware of middlewares) {
const instance = new middleware(this.getApp());
if (instance instanceof FileUploadMiddleware) {
this.fileUploadFormRouter.use(this.wrap(instance.getRequestHandler()));
} else {
this.router.use(this.wrap(instance.getRequestHandler()));
}
logger.info('Installed ' + middleware.name + ' on ' + this.getRoutesPrefix());
}
this.router.use(handler);
}
protected get(
@ -141,16 +161,33 @@ export default abstract class Controller {
routePath = (prefix !== '/' ? prefix : '') + path;
}
if (typeof routePath !== 'string') {
logger.warn(`Cannot assign path to route ${routeName}.`);
return;
if (!Controller.routes[routeName]) {
if (typeof routePath === 'string') {
log.info(`Route ${routeName} has path ${routePath}`);
Controller.routes[routeName] = routePath;
} else {
log.warn(`Cannot assign path to route ${routeName}.`);
}
}
}
protected async validate(
validationMap: { [p: string]: Validator<unknown> },
body: { [p: string]: unknown },
): Promise<void> {
const bag = new ValidationBag();
for (const p of Object.keys(validationMap)) {
try {
await validationMap[p].execute(p, body[p], false);
} catch (e) {
if (e instanceof ValidationBag) {
bag.addBag(e);
} else throw e;
}
}
if (registerRoute(routeName, routePath)) {
logger.info(`Route ${routeName} has path ${routePath}`);
} else {
logger.warn(`Couldn't register ${routeName} for path ${routePath}`);
}
if (bag.hasMessages()) throw bag;
}
protected getApp(): Application {
@ -162,3 +199,5 @@ export default abstract class Controller {
this.app = app;
}
}
export type RouteParams = { [p: string]: string } | string[] | string | number;

View File

@ -1,20 +1,7 @@
import {Type} from "./Utils.js";
import {Type} from "./Utils";
export default interface Extendable<ComponentClass> {
as<C extends ComponentClass>(type: Type<C>): C;
asOptional<C extends ComponentClass>(type: Type<C>): C | null;
has<C extends ComponentClass>(type: Type<C>): boolean;
/**
* @throws MissingComponentError
*/
require<C extends ComponentClass>(type: Type<C>): void;
}
export class MissingComponentError<ComponentClass> extends Error {
public constructor(type: Type<ComponentClass>) {
super(`Required component ${type.name} was not found.`);
}
}

View File

@ -1,16 +1,15 @@
import {IncomingForm} from "formidable";
import Middleware from "./Middleware";
import {NextFunction, Request, Response} from "express";
import formidable, {Options} from "formidable";
import {FileError, ValidationBag} from "./db/Validator.js";
import Middleware from "./Middleware.js";
import {FileError, ValidationBag} from "./db/Validator";
export default abstract class FileUploadMiddleware extends Middleware {
protected abstract getFormidableOptions(): Options;
protected abstract makeForm(): IncomingForm;
protected abstract getDefaultField(): string;
public async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const form = formidable(this.getFormidableOptions());
const form = this.makeForm();
try {
await new Promise<void>((resolve, reject) => {
form.parse(req, (err, fields, files) => {
@ -25,7 +24,7 @@ export default abstract class FileUploadMiddleware extends Middleware {
});
} catch (e) {
const bag = new ValidationBag();
const fileError = new FileError(String(e));
const fileError = new FileError(e);
fileError.thingName = this.getDefaultField();
bag.addMessage(fileError);
next(bag);

View File

@ -1,4 +1,4 @@
import {WrappingError} from "./Utils.js";
import {WrappingError} from "./Utils";
export abstract class HttpError extends WrappingError {
public readonly instructions: string;
@ -12,7 +12,7 @@ export abstract class HttpError extends WrappingError {
return this.constructor.name;
}
public abstract get errorCode(): number;
abstract get errorCode(): number;
}
export class BadRequestError extends HttpError {
@ -69,11 +69,11 @@ export class NotFoundHttpError extends BadRequestError {
}
export class TooManyRequestsHttpError extends BadRequestError {
public constructor(retryIn: number, jailName: string, cause?: Error) {
public constructor(retryIn: number, cause?: Error) {
super(
`You're making too many requests!`,
`We need some rest. Please retry in ${Math.floor(retryIn / 1000)} seconds.`,
jailName,
'',
cause,
);
}

View File

@ -1,40 +1,11 @@
import {AsyncLocalStorage} from "async_hooks";
import {RequestHandler} from "express";
import {nanoid} from "nanoid";
import {v4 as uuid} from "uuid";
import {Logger as TsLogger} from "tslog";
const requestIdStorage: AsyncLocalStorage<string> = new AsyncLocalStorage();
export const log = new TsLogger();
export const logger = new TsLogger({
requestId: (): string => {
return requestIdStorage.getStore() as string;
},
delimiter: '\t',
maskValuesOfKeys: [
'Authorization',
'password',
'password_confirmation',
'secret',
],
displayFunctionName: false,
displayFilePath: 'hidden',
});
export const loggingContextMiddleware: RequestHandler = (req, res, next) => {
requestIdStorage.run(nanoid(8), () => {
next();
export function makeUniqueLogger(): TsLogger {
const id = uuid();
return log.getChildLogger({
requestId: id,
});
};
export const preventContextCorruptionMiddleware = (delegate: RequestHandler): RequestHandler => (
req,
res,
next,
) => {
const data = requestIdStorage.getStore() as string;
delegate(req, res, (err?: unknown | 'router') => {
requestIdStorage.enterWith(data);
next(err);
});
};
}

151
src/Mail.ts Normal file
View File

@ -0,0 +1,151 @@
import nodemailer, {SentMessageInfo, Transporter} from "nodemailer";
import config from "config";
import {Options} from "nodemailer/lib/mailer";
import {Environment} from 'nunjucks';
import * as util from "util";
import {WrappingError} from "./Utils";
import mjml2html from "mjml";
import {log} from "./Logger";
import Controller from "./Controller";
import {ParsedUrlQueryInput} from "querystring";
export default class Mail {
private static transporter?: Transporter;
private static getTransporter(): Transporter {
if (!this.transporter) throw new MailError('Mail system was not prepared.');
return this.transporter;
}
public static async prepare(): Promise<void> {
const transporter = nodemailer.createTransport({
host: config.get('mail.host'),
port: config.get('mail.port'),
requireTLS: config.get('mail.secure'), // STARTTLS
auth: {
user: config.get('mail.username'),
pass: config.get('mail.password'),
},
tls: {
rejectUnauthorized: !config.get('mail.allow_invalid_tls'),
},
});
try {
await util.promisify(transporter.verify)();
this.transporter = transporter;
} catch (e) {
throw new MailError('Connection to mail service unsuccessful.', e);
}
log.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`);
}
public static end(): void {
if (this.transporter) this.transporter.close();
}
public static parse(
environment: Environment,
template: string,
data: { [p: string]: unknown },
textOnly: boolean,
): string {
data.text = textOnly;
const nunjucksResult = environment.render(template, data);
if (textOnly) return nunjucksResult;
const mjmlResult = mjml2html(nunjucksResult, {});
if (mjmlResult.errors.length > 0) {
throw new MailError(`Error while parsing mail template ${template}: ${JSON.stringify(mjmlResult.errors, null, 4)}`);
}
return mjmlResult.html;
}
private readonly options: Options = {};
public constructor(
private readonly environment: Environment,
private readonly template: MailTemplate,
private readonly data: ParsedUrlQueryInput = {},
) {
this.template = template;
this.data = data;
this.options.subject = this.template.getSubject(data);
this.verifyData();
}
private verifyData() {
for (const forbiddenField of [
'to',
]) {
if (this.data[forbiddenField] !== undefined) {
throw new MailError(`Can't use reserved data.${forbiddenField}.`);
}
}
}
public async send(...to: string[]): Promise<SentMessageInfo[]> {
const results = [];
for (const destEmail of to) {
// Reset options
this.options.html = this.options.text = undefined;
// Set options
this.options.to = destEmail;
this.options.from = {
name: config.get('mail.from_name'),
address: config.get('mail.from'),
};
// Set data
this.data.mail_subject = this.options.subject;
this.data.mail_to = this.options.to;
this.data.mail_link = config.get<string>('base_url') +
Controller.route('mail', [this.template.template], this.data);
this.data.app = config.get('app');
// Log
log.debug('Send mail', this.options);
// Render email
this.options.html = Mail.parse(this.environment, 'mails/' + this.template.template + '.mjml.njk',
this.data, false);
this.options.text = Mail.parse(this.environment, 'mails/' + this.template.template + '.mjml.njk',
this.data, true);
// Send email
results.push(await Mail.getTransporter().sendMail(this.options));
}
return results;
}
}
export class MailTemplate {
private readonly _template: string;
private readonly subject: (data: { [p: string]: unknown }) => string;
public constructor(template: string, subject: (data: { [p: string]: unknown }) => string) {
this._template = template;
this.subject = subject;
}
public get template(): string {
return this._template;
}
public getSubject(data: { [p: string]: unknown }): string {
return `${config.get('app.name')} - ${this.subject(data)}`;
}
}
class MailError extends WrappingError {
public constructor(message: string = 'An error occurred while sending mail.', cause?: Error) {
super(message, cause);
}
}

View File

@ -1,12 +1,9 @@
import config from "config";
import MailTemplate from "./mail/MailTemplate.js";
import {MailTemplate} from "./Mail";
export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link',
data => data.type === 'register' ?
'Registration' :
'Login magic link',
data => data.type === 'register' ? 'Registration' : 'Login magic link',
);
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
@ -16,15 +13,5 @@ export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplat
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'pending_account_review',
() => `A new account is pending review on ${config.get<string>('app.name')}`,
);
export const ADD_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'add_email',
(data) => `Add ${data.email} address to your ${config.get<string>('app.name')} account.`,
);
export const REMOVE_PASSWORD_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'remove_password',
() => `Remove password from your ${config.get<string>('app.name')} account.`,
() => 'A new account is pending review on ' + config.get<string>('domain'),
);

View File

@ -1,8 +1,7 @@
import {RequestHandler} from "express";
import {NextFunction, Request, Response} from "express-serve-static-core";
import Application from "./Application.js";
import {Type} from "./Utils.js";
import Application from "./Application";
import {Type} from "./Utils";
export default abstract class Middleware {
public constructor(
@ -11,7 +10,7 @@ export default abstract class Middleware {
}
protected abstract handle(req: Request, res: Response, next: NextFunction): Promise<void>;
protected abstract async handle(req: Request, res: Response, next: NextFunction): Promise<void>;
public getRequestHandler(): RequestHandler {
return async (req, res, next): Promise<void> => {

24
src/Pagination.ts Normal file
View File

@ -0,0 +1,24 @@
import Model from "./db/Model";
export default class Pagination<T extends Model> {
private readonly models: T[];
public readonly page: number;
public readonly perPage: number;
public readonly totalCount: number;
public constructor(models: T[], page: number, perPage: number, totalCount: number) {
this.models = models;
this.page = page;
this.perPage = perPage;
this.totalCount = totalCount;
}
public hasPrevious(): boolean {
return this.page > 1;
}
public hasNext(): boolean {
return this.models.length >= this.perPage && this.page * this.perPage < this.totalCount;
}
}

View File

@ -1,51 +0,0 @@
import config from "config";
import cookie from "cookie";
import cookieParser from "cookie-parser";
import {Request} from "express";
import {Session} from "express-session";
import {IncomingMessage} from "http";
import {WebSocket} from "ws";
import Application from "./Application.js";
import RedisComponent from "./components/RedisComponent.js";
import {logger} from "./Logger.js";
import WebSocketListener from "./WebSocketListener.js";
export default abstract class SessionWebSocketListener<A extends Application> extends WebSocketListener<A> {
public async handle(socket: WebSocket, request: IncomingMessage): Promise<void> {
socket.once('message', (data, isBinary) => {
if (isBinary) return socket.close(1003);
const cookies = cookie.parse(data.toString());
const sid = cookieParser.signedCookie(cookies['connect.sid'], config.get('session.secret'));
if (!sid) {
socket.close(1002, 'Could not decrypt provided session cookie.');
return;
}
const store = this.getApp().as(RedisComponent).getStore();
store.get(sid, (err, session) => {
if (err || !session) {
logger.error(err, 'Error while initializing session in websocket for sid ' + sid);
socket.close(1011);
return;
}
session.id = sid;
store.createSession(<Request>request, session);
this.handleSessionSocket(socket, request, session as Session).catch(err => {
logger.error(err, 'Error in websocket listener.');
});
});
});
}
protected abstract handleSessionSocket(
socket: WebSocket,
request: IncomingMessage,
session: Session,
): Promise<void>;
}

View File

@ -1,171 +0,0 @@
import {Express} from "express";
import Application from "./Application.js";
import AccountController from "./auth/AccountController.js";
import AuthComponent from "./auth/AuthComponent.js";
import AuthController from "./auth/AuthController.js";
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration.js";
import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration.js";
import MagicLinkAuthMethod from "./auth/magic_link/MagicLinkAuthMethod.js";
import MagicLinkController from "./auth/magic_link/MagicLinkController.js";
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener.js";
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration.js";
import AddApprovedFieldToUsersTableMigration from "./auth/migrations/AddApprovedFieldToUsersTableMigration.js";
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration.js";
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration.js";
import CreateUsersAndUserEmailsTableMigration from "./auth/migrations/CreateUsersAndUserEmailsTableMigration.js";
import AddPasswordToUsersMigration from "./auth/password/AddPasswordToUsersMigration.js";
import PasswordAuthMethod from "./auth/password/PasswordAuthMethod.js";
import CsrfProtectionComponent from "./components/CsrfProtectionComponent.js";
import ExpressAppComponent from "./components/ExpressAppComponent.js";
import FormHelperComponent from "./components/FormHelperComponent.js";
import FrontendToolsComponent from "./components/FrontendToolsComponent.js";
import LogRequestsComponent from "./components/LogRequestsComponent.js";
import MailComponent from "./components/MailComponent.js";
import MaintenanceComponent from "./components/MaintenanceComponent.js";
import MysqlComponent from "./components/MysqlComponent.js";
import PreviousUrlComponent from "./components/PreviousUrlComponent.js";
import RedisComponent from "./components/RedisComponent.js";
import ServeStaticDirectoryComponent from "./components/ServeStaticDirectoryComponent.js";
import SessionComponent from "./components/SessionComponent.js";
import WebSocketServerComponent from "./components/WebSocketServerComponent.js";
import Controller from "./Controller.js";
import Migration, {MigrationType} from "./db/Migration.js";
import AssetCompiler from "./frontend/AssetCompiler.js";
import CopyAssetPreCompiler from "./frontend/CopyAssetPreCompiler.js";
import MailViewEngine from "./frontend/MailViewEngine.js";
import NunjucksViewEngine from "./frontend/NunjucksViewEngine.js";
import ScssAssetPreCompiler from "./frontend/ScssAssetPreCompiler.js";
import SvelteViewEngine from "./frontend/SvelteViewEngine.js";
import TypeScriptPreCompiler from "./frontend/TypeScriptPreCompiler.js";
import BackendController from "./helpers/BackendController.js";
import MailController from "./mail/MailController.js";
import {MAGIC_LINK_MAIL} from "./Mails.js";
import CreateMigrationsTable from "./migrations/CreateMigrationsTable.js";
export const MIGRATIONS = [
CreateMigrationsTable,
CreateUsersAndUserEmailsTableMigration,
AddPasswordToUsersMigration,
CreateMagicLinksTableMigration,
AddNameToUsersMigration,
MakeMagicLinksSessionNotUniqueMigration,
AddUsedToMagicLinksMigration,
AddNameChangedAtToUsersMigration,
];
export default class TestApp extends Application {
private readonly addr: string;
private readonly port: number;
public constructor(
version: string,
addr: string,
port: number,
ignoreCommandLine: boolean = false,
private readonly approvalMode: boolean,
) {
super(version, ignoreCommandLine);
this.addr = addr;
this.port = port;
}
protected getMigrations(): MigrationType<Migration>[] {
const migrations = [...MIGRATIONS];
if (this.approvalMode) {
migrations.push(AddApprovedFieldToUsersTableMigration);
}
return migrations;
}
protected async init(): Promise<void> {
this.registerComponents();
this.registerWebSocketListeners();
this.registerControllers();
}
protected registerComponents(): void {
// Base
this.use(new ExpressAppComponent(this.addr, this.port));
this.use(new LogRequestsComponent());
// Static files
this.use(new ServeStaticDirectoryComponent('public'));
// Maintenance
this.use(new MaintenanceComponent());
// Dynamic views and routes
const intermediateDirectory = 'intermediates/assets';
const assetCompiler = new AssetCompiler(intermediateDirectory, 'public');
this.use(new FrontendToolsComponent(
assetCompiler,
new CopyAssetPreCompiler(intermediateDirectory, '', 'json', ['test/assets'], false),
new ScssAssetPreCompiler(intermediateDirectory, assetCompiler.targetDir, 'scss', ['test/assets']),
new CopyAssetPreCompiler(intermediateDirectory, 'img', 'svg', ['test/assets'], true),
new TypeScriptPreCompiler(intermediateDirectory, ['test/assets']),
new SvelteViewEngine(intermediateDirectory, 'test/assets'),
new NunjucksViewEngine(intermediateDirectory, 'test/assets'),
));
this.use(new PreviousUrlComponent());
// Services
this.use(new MysqlComponent());
this.use(new MailComponent(new MailViewEngine('intermediates/assets', 'test/assets')));
// Session
this.use(new RedisComponent());
this.use(new SessionComponent(this.as(RedisComponent)));
// Utils
this.use(new FormHelperComponent());
// Middlewares
this.use(new CsrfProtectionComponent());
// Auth
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
// WebSocket server
this.use(new WebSocketServerComponent());
}
protected registerWebSocketListeners(): void {
this.use(new MagicLinkWebSocketListener());
}
protected registerControllers(): void {
this.use(new MailController());
this.use(new AuthController());
this.use(new AccountController());
this.use(new BackendController());
this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));
// Special home controller
this.use(new class extends Controller {
public routes(): void {
this.get('/', (req, res) => {
res.formatViewData('home');
}, 'home');
this.get('/tests', (req, res) => {
res.formatViewData('tests');
}, 'tests');
this.get('/design', (req, res) => {
req.flash('success', 'Success.');
req.flash('info', 'Info.');
req.flash('warning', 'Warning.');
req.flash('error', 'Error.');
req.flash('error-alert', 'Error alert.');
res.formatViewData('design');
}, 'design');
}
}());
}
public getExpressApp(): Express {
return this.as(ExpressAppComponent).getExpressApp();
}
}

View File

@ -1,5 +1,4 @@
import {TooManyRequestsHttpError} from "./HttpError.js";
import {logger} from "./Logger.js";
import {TooManyRequestsHttpError} from "./HttpError";
export default class Throttler {
private static readonly throttles: Record<string, Throttle | undefined> = {};
@ -26,8 +25,7 @@ export default class Throttler {
jailPeriod: number = 30 * 1000,
): void {
let throttle = this.throttles[action];
if (!throttle)
throttle = this.throttles[action] = new Throttle(action, max, resetPeriod, holdPeriod, jailPeriod);
if (!throttle) throttle = this.throttles[action] = new Throttle(max, resetPeriod, holdPeriod, jailPeriod);
throttle.trigger(id);
}
@ -37,7 +35,6 @@ export default class Throttler {
}
class Throttle {
private readonly jailName: string;
private readonly max: number;
private readonly resetPeriod: number;
private readonly holdPeriod: number;
@ -48,8 +45,7 @@ class Throttle {
jailed?: number;
} | undefined> = {};
public constructor(jailName: string, max: number, resetPeriod: number, holdPeriod: number, jailPeriod: number) {
this.jailName = jailName;
public constructor(max: number, resetPeriod: number, holdPeriod: number, jailPeriod: number) {
this.max = max;
this.resetPeriod = resetPeriod;
this.holdPeriod = holdPeriod;
@ -79,15 +75,11 @@ class Throttle {
if (trigger.count > this.max) {
trigger.jailed = currentDate;
const unjailedIn = trigger.jailed + this.jailPeriod - currentDate;
logger.info(`Jail ${this.jailName} triggered by ${id} and will be unjailed in ${unjailedIn}ms.`);
return this.throw(unjailedIn);
return this.throw(trigger.jailed + this.jailPeriod - currentDate);
}
}
protected throw(unjailedIn: number) {
throw new TooManyRequestsHttpError(unjailedIn, this.jailName);
throw new TooManyRequestsHttpError(unjailedIn);
}
}

View File

@ -1,5 +1,4 @@
import fs, {promises as afs} from "fs";
import path from "path";
import * as crypto from "crypto";
export async function sleep(ms: number): Promise<void> {
return await new Promise(resolve => {
@ -24,6 +23,17 @@ export abstract class WrappingError extends Error {
}
}
export function cryptoRandomDictionary(size: number, dictionary: string): string {
const randomBytes = crypto.randomBytes(size);
const output = new Array(size);
for (let i = 0; i < size; i++) {
output[i] = dictionary[Math.floor(randomBytes[i] / 255 * dictionary.length)];
}
return output.join('');
}
export type Type<T> = { new(...args: never[]): T };
export function bufferToUuid(buffer: Buffer): string {
@ -47,35 +57,3 @@ export function getMethods<T extends { [p: string]: unknown }>(obj: T): string[]
} while (currentObj);
return [...properties.keys()].filter(item => typeof obj[item] === 'function');
}
export async function listFilesRecursively(dir: string): Promise<string[]> {
const localFiles = await afs.readdir(dir);
const files: string[] = [];
for (const file of localFiles.map(file => path.join(dir, file))) {
const stat = await afs.stat(file);
if (stat.isDirectory()) {
files.push(...await listFilesRecursively(file));
} else {
files.push(file);
}
}
return files;
}
export async function doesFileExist(file: string): Promise<boolean> {
return await new Promise<boolean>((resolve, reject) => {
fs.stat(file, err => {
if (err) {
if (err.code === 'ENOENT') {
return resolve(false);
} else {
return reject(err);
}
} else {
return resolve(true);
}
});
});
}

View File

@ -1,7 +1,6 @@
import {IncomingMessage} from "http";
import WebSocket from "ws";
import Application from "./Application.js";
import {IncomingMessage} from "http";
import Application from "./Application";
export default abstract class WebSocketListener<T extends Application> {
private app!: T;
@ -16,8 +15,9 @@ export default abstract class WebSocketListener<T extends Application> {
public abstract path(): string;
public abstract handle(
public abstract async handle(
socket: WebSocket,
request: IncomingMessage,
session: Express.Session | null,
): Promise<void>;
}

View File

@ -1,239 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg5"
inkscape:version="1.1 (c4e8f9ed74, 2021-05-24)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:document-units="px"
showgrid="true"
units="px"
width="256px"
showguides="true"
inkscape:zoom="3.1108586"
inkscape:cx="167.96006"
inkscape:cy="124.88514"
inkscape:window-width="2560"
inkscape:window-height="1408"
inkscape:window-x="1920"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="g2910">
<inkscape:grid
type="xygrid"
id="grid9"
empspacing="8"
color="#808080"
opacity="0.25098039"
empcolor="#3b3bc4"
empopacity="0.25098039" />
</sodipodi:namedview>
<defs
id="defs2">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4993">
<path
style="font-variation-settings:normal;opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.23333;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
sodipodi:type="inkscape:offset"
inkscape:radius="0"
inkscape:original="M 38.130859 9.1289062 C 38.130859 9.1289062 36.869291 14.550899 33.769531 22.080078 C 31.015624 28.769197 25.443612 29.282491 25.431641 38.761719 C 25.421841 46.502055 31.116842 51.460937 38.130859 51.460938 C 45.144876 51.460938 50.795057 46.501973 50.832031 38.761719 C 50.880267 28.66385 49.355438 30.099351 44.685547 20.40625 C 41.206475 13.184883 38.130859 9.1289062 38.130859 9.1289062 z "
xlink:href="#path63"
id="path4995"
inkscape:href="#path63"
d="m 38.130859,9.4160156 c 0,0 -12.699218,21.1661454 -12.699218,29.6328124 0,7.014017 5.685201,12.701172 12.699218,12.701172 7.014017,0 12.701172,-5.687155 12.701172,-12.701172 0,-8.466667 -12.701172,-29.6328124 -12.701172,-29.6328124 z"
transform="translate(-4.2646454,3.5720328)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath5199">
<path
style="font-variation-settings:normal;opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.23333;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
sodipodi:type="inkscape:offset"
inkscape:radius="0"
inkscape:original="M 36.546875 7.2324219 C 36.546875 7.2324219 23.845703 28.40052 23.845703 36.867188 C 23.845703 43.881204 29.532858 49.566406 36.546875 49.566406 C 43.560892 49.566406 49.246094 43.881204 49.246094 36.867188 C 49.246094 28.40052 36.546875 7.2324219 36.546875 7.2324219 z "
xlink:href="#path4972"
id="path5201"
inkscape:href="#path4972"
d="m 36.242188,6.9941406 c 0,0 -12.701172,21.1680984 -12.701172,29.6347654 0,7.014017 5.687155,12.699219 12.701172,12.699219 7.014017,0 12.699218,-5.685202 12.699218,-12.699219 0,-8.466667 -12.699219,-29.6347654 -12.699218,-29.6347654 z"
transform="translate(5.7867919,-2.9996614)" />
</clipPath>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g2910"
style="display:inline">
<g
id="g4378"
style="display:inline"
transform="translate(-1.0583342)">
<rect
style="opacity:1;fill:#ff900d;fill-opacity:1;stroke:#ff900d;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000;stop-opacity:1"
id="rect21528"
width="46.566666"
height="46.566666"
x="20.108334"
y="2.1166666" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:#fffdff;stroke-width:1.05833;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000"
id="rect52610-8"
width="38.099995"
height="2.1166658"
x="28.575006"
y="21.166666" />
</g>
<g
aria-label="express"
id="text15483-1"
style="font-size:10.0542px;line-height:1.25;font-family:Rubik;-inkscape-font-specification:Rubik;text-align:end;text-anchor:end;display:none;fill:#fefefe;stroke-width:0.529167"
transform="translate(3.9913951,0.37437841)">
<path
d="m 27.144137,18.146446 q -1.035579,0 -1.648883,-0.633413 -0.613305,-0.643466 -0.67363,-1.749425 -0.01005,-0.130704 -0.01005,-0.331787 0,-0.211138 0.01005,-0.341842 0.04022,-0.713846 0.331788,-1.246717 0.291571,-0.542925 0.794279,-0.834495 0.512763,-0.291571 1.196446,-0.291571 0.764117,0 1.276879,0.321733 0.522817,0.321733 0.794279,0.914929 0.271463,0.593196 0.271463,1.387475 v 0.170921 q 0,0.110596 -0.07038,0.170921 -0.06032,0.06032 -0.160867,0.06032 H 25.77677 q 0,0.01005 0,0.04022 0,0.03016 0,0.05027 0.02011,0.412221 0.180975,0.774171 0.160867,0.351896 0.462492,0.573088 0.301625,0.221191 0.7239,0.221191 0.36195,0 0.60325,-0.110595 0.2413,-0.110596 0.392112,-0.2413 0.150813,-0.140759 0.201084,-0.211138 0.09049,-0.130704 0.140758,-0.150812 0.05027,-0.03016 0.160867,-0.03016 h 0.4826 q 0.100541,0 0.160866,0.06032 0.07038,0.05027 0.06032,0.150813 -0.01005,0.150812 -0.160866,0.372004 -0.150813,0.211137 -0.432329,0.422275 -0.281517,0.211137 -0.683684,0.351896 -0.402166,0.130704 -0.924983,0.130704 z M 25.77677,15.049762 h 2.754842 V 15.0196 q 0,-0.452438 -0.170921,-0.804333 -0.160867,-0.351896 -0.472546,-0.55298 -0.311679,-0.211137 -0.744008,-0.211137 -0.432329,0 -0.744008,0.211137 -0.301625,0.201084 -0.462492,0.55298 -0.160867,0.351895 -0.160867,0.804333 z"
id="path2866"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 30.34322,18.045904 q -0.08043,0 -0.150813,-0.06032 -0.06032,-0.06033 -0.06032,-0.150812 0,-0.03016 0.01006,-0.07038 0.02011,-0.05027 0.06032,-0.110595 l 1.749425,-2.282296 -1.63883,-2.161646 q -0.04022,-0.06032 -0.06032,-0.100542 -0.01005,-0.04022 -0.01005,-0.08043 0,-0.09049 0.06032,-0.150813 0.06032,-0.06033 0.150813,-0.06033 h 0.512762 q 0.110596,0 0.160867,0.06033 0.06032,0.05027 0.100542,0.100542 l 1.337204,1.739371 1.337204,-1.729317 q 0.04022,-0.05027 0.09049,-0.110596 0.06032,-0.06033 0.170921,-0.06033 h 0.492654 q 0.09049,0 0.150813,0.06033 0.06032,0.06033 0.06032,0.150813 0,0.04022 -0.02011,0.08043 -0.01006,0.04022 -0.05027,0.100542 l -1.658938,2.181754 1.749425,2.262188 q 0.04022,0.06033 0.05027,0.100541 0.02011,0.04022 0.02011,0.08043 0,0.09049 -0.06032,0.150812 -0.06032,0.06032 -0.150812,0.06032 h -0.532871 q -0.100542,0 -0.160867,-0.05027 -0.06032,-0.05027 -0.100541,-0.100541 l -1.417638,-1.839913 -1.417637,1.839913 q -0.04022,0.04022 -0.100542,0.100541 -0.05027,0.05027 -0.160867,0.05027 z"
id="path2868"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 36.245011,19.956196 q -0.100542,0 -0.170921,-0.07038 -0.06032,-0.06032 -0.06032,-0.160867 v -6.675967 q 0,-0.100541 0.06032,-0.160866 0.07038,-0.07038 0.170921,-0.07038 h 0.462491 q 0.100542,0 0.160867,0.07038 0.07038,0.06033 0.07038,0.160866 v 0.442384 q 0.2413,-0.331788 0.643467,-0.55298 0.402167,-0.221191 1.005417,-0.221191 0.563033,0 0.955146,0.191029 0.402166,0.191029 0.65352,0.522817 0.261409,0.331787 0.392113,0.754062 0.130704,0.422275 0.140758,0.904875 0.01005,0.160867 0.01005,0.341842 0,0.180975 -0.01005,0.341841 -0.01005,0.472546 -0.140758,0.904875 -0.130704,0.422275 -0.392113,0.754063 -0.251354,0.321733 -0.65352,0.522817 -0.392113,0.191029 -0.955146,0.191029 -0.583142,0 -0.985309,-0.211138 -0.392112,-0.221191 -0.643466,-0.542925 v 2.332567 q 0,0.100542 -0.06032,0.160867 -0.06033,0.07038 -0.170921,0.07038 z m 2.131483,-2.624138 q 0.522817,0 0.814388,-0.221191 0.301625,-0.231246 0.432329,-0.593196 0.130704,-0.372004 0.150812,-0.794279 0.01006,-0.291571 0,-0.583142 -0.02011,-0.422275 -0.150812,-0.784225 -0.130704,-0.372004 -0.432329,-0.593196 -0.291571,-0.231246 -0.814388,-0.231246 -0.492654,0 -0.804333,0.231246 -0.301625,0.231246 -0.452438,0.593196 -0.140758,0.351896 -0.160866,0.7239 -0.01006,0.160867 -0.01006,0.382058 0,0.221192 0.01006,0.392113 0.01005,0.351896 0.160866,0.693737 0.160867,0.341842 0.472546,0.563034 0.311679,0.221191 0.784225,0.221191 z"
id="path2870"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 42.227228,18.045904 q -0.100542,0 -0.170921,-0.06032 -0.06032,-0.07038 -0.06032,-0.170921 v -4.755621 q 0,-0.100541 0.06032,-0.17092 0.07038,-0.07038 0.170921,-0.07038 h 0.462492 q 0.100541,0 0.170921,0.07038 0.07038,0.07038 0.07038,0.17092 v 0.442384 q 0.201083,-0.341842 0.552979,-0.512763 0.351896,-0.170921 0.84455,-0.170921 h 0.402167 q 0.100541,0 0.160866,0.07038 0.06032,0.06033 0.06032,0.160866 v 0.412221 q 0,0.100542 -0.06032,0.160867 -0.06032,0.06033 -0.160866,0.06033 h -0.60325 q -0.542925,0 -0.854605,0.321733 -0.311679,0.311679 -0.311679,0.854604 v 2.955925 q 0,0.100542 -0.07038,0.170921 -0.07038,0.06032 -0.170921,0.06032 z"
id="path2872"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 47.799738,18.146446 q -1.035579,0 -1.648883,-0.633413 -0.613304,-0.643466 -0.673629,-1.749425 -0.01005,-0.130704 -0.01005,-0.331787 0,-0.211138 0.01005,-0.341842 0.04022,-0.713846 0.331787,-1.246717 0.291571,-0.542925 0.79428,-0.834495 0.512762,-0.291571 1.196445,-0.291571 0.764117,0 1.27688,0.321733 0.522816,0.321733 0.794279,0.914929 0.271462,0.593196 0.271462,1.387475 v 0.170921 q 0,0.110596 -0.07038,0.170921 -0.06033,0.06032 -0.160867,0.06032 h -3.478741 q 0,0.01005 0,0.04022 0,0.03016 0,0.05027 0.02011,0.412221 0.180975,0.774171 0.160866,0.351896 0.462491,0.573088 0.301625,0.221191 0.7239,0.221191 0.36195,0 0.60325,-0.110595 0.2413,-0.110596 0.392113,-0.2413 0.150812,-0.140759 0.201083,-0.211138 0.09049,-0.130704 0.140759,-0.150812 0.05027,-0.03016 0.160866,-0.03016 h 0.4826 q 0.100542,0 0.160867,0.06032 0.07038,0.05027 0.06032,0.150813 -0.01005,0.150812 -0.160867,0.372004 -0.150812,0.211137 -0.432329,0.422275 -0.281517,0.211137 -0.683683,0.351896 -0.402167,0.130704 -0.924984,0.130704 z m -1.367366,-3.096684 h 2.754841 V 15.0196 q 0,-0.452438 -0.17092,-0.804333 -0.160867,-0.351896 -0.472546,-0.55298 -0.311679,-0.211137 -0.744009,-0.211137 -0.432329,0 -0.744008,0.211137 -0.301625,0.201084 -0.462492,0.55298 -0.160866,0.351895 -0.160866,0.804333 z"
id="path2874"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 53.158603,18.146446 q -0.563033,0 -0.9652,-0.140759 -0.402167,-0.140758 -0.653521,-0.341841 -0.251354,-0.201084 -0.382058,-0.402167 -0.12065,-0.201083 -0.130704,-0.321733 -0.01005,-0.110596 0.07038,-0.170921 0.08043,-0.06032 0.160867,-0.06032 h 0.442383 q 0.06032,0 0.09049,0.02011 0.04022,0.01005 0.100542,0.08043 0.130704,0.140758 0.291571,0.281516 0.160867,0.140759 0.392113,0.231246 0.2413,0.09049 0.593195,0.09049 0.512763,0 0.84455,-0.19103 0.331788,-0.201083 0.331788,-0.583141 0,-0.251354 -0.140758,-0.402167 -0.130705,-0.150812 -0.4826,-0.271462 -0.341842,-0.12065 -0.945092,-0.251355 -0.60325,-0.140758 -0.955146,-0.341841 -0.351896,-0.211138 -0.502708,-0.492654 -0.150813,-0.291571 -0.150813,-0.653521 0,-0.372004 0.221192,-0.713846 0.221191,-0.351896 0.643467,-0.573088 0.432329,-0.221191 1.075795,-0.221191 0.522817,0 0.894821,0.130704 0.372004,0.130704 0.613304,0.331787 0.2413,0.19103 0.36195,0.382059 0.12065,0.191029 0.130705,0.321733 0.01005,0.100542 -0.06033,0.170921 -0.07038,0.06033 -0.160867,0.06033 h -0.422275 q -0.07038,0 -0.12065,-0.03016 -0.04022,-0.03016 -0.08043,-0.07038 -0.100542,-0.130704 -0.2413,-0.261408 -0.130705,-0.130704 -0.351896,-0.211138 -0.211138,-0.09049 -0.563034,-0.09049 -0.502708,0 -0.754062,0.211137 -0.251354,0.211138 -0.251354,0.532871 0,0.191029 0.110596,0.341842 0.110595,0.150812 0.422275,0.271462 0.311679,0.12065 0.924983,0.261409 0.663575,0.130704 1.045633,0.351896 0.382059,0.221191 0.542925,0.512762 0.160867,0.291571 0.160867,0.673629 0,0.422275 -0.251354,0.774171 -0.2413,0.351896 -0.7239,0.563033 -0.472546,0.201084 -1.176338,0.201084 z"
id="path2876"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 58.286212,18.146446 q -0.563033,0 -0.9652,-0.140759 -0.402166,-0.140758 -0.65352,-0.341841 -0.251355,-0.201084 -0.382059,-0.402167 -0.12065,-0.201083 -0.130704,-0.321733 -0.01005,-0.110596 0.07038,-0.170921 0.08043,-0.06032 0.160867,-0.06032 h 0.442383 q 0.06032,0 0.09049,0.02011 0.04022,0.01005 0.100541,0.08043 0.130705,0.140758 0.291571,0.281516 0.160867,0.140759 0.392113,0.231246 0.2413,0.09049 0.593196,0.09049 0.512762,0 0.84455,-0.19103 0.331787,-0.201083 0.331787,-0.583141 0,-0.251354 -0.140758,-0.402167 -0.130704,-0.150812 -0.4826,-0.271462 -0.341842,-0.12065 -0.945092,-0.251355 -0.60325,-0.140758 -0.955146,-0.341841 -0.351896,-0.211138 -0.502708,-0.492654 -0.150813,-0.291571 -0.150813,-0.653521 0,-0.372004 0.221192,-0.713846 0.221192,-0.351896 0.643467,-0.573088 0.432329,-0.221191 1.075796,-0.221191 0.522816,0 0.89482,0.130704 0.372005,0.130704 0.613305,0.331787 0.2413,0.19103 0.36195,0.382059 0.12065,0.191029 0.130704,0.321733 0.01005,0.100542 -0.06033,0.170921 -0.07038,0.06033 -0.160867,0.06033 h -0.422275 q -0.07038,0 -0.12065,-0.03016 -0.04022,-0.03016 -0.08043,-0.07038 -0.100542,-0.130704 -0.2413,-0.261408 -0.130704,-0.130704 -0.351896,-0.211138 -0.211138,-0.09049 -0.563033,-0.09049 -0.502709,0 -0.754063,0.211137 -0.251354,0.211138 -0.251354,0.532871 0,0.191029 0.110596,0.341842 0.110596,0.150812 0.422275,0.271462 0.311679,0.12065 0.924983,0.261409 0.663575,0.130704 1.045633,0.351896 0.382059,0.221191 0.542925,0.512762 0.160867,0.291571 0.160867,0.673629 0,0.422275 -0.251354,0.774171 -0.2413,0.351896 -0.7239,0.563033 -0.472546,0.201084 -1.176338,0.201084 z"
id="path2878"
style="fill:#ffffff;fill-opacity:1" />
</g>
</g>
<g
id="g1018"
style="display:inline">
<g
id="g4374"
style="display:inline"
transform="translate(-1.0583342)">
<rect
style="opacity:1;fill:#ff3e00;fill-opacity:1;stroke:#ff3e00;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000;stop-opacity:1"
id="rect21526"
width="46.566666"
height="46.566666"
x="11.641667"
y="10.583333" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:#fffdff;stroke-width:1.05833;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000"
id="rect52610-6"
width="38.099995"
height="2.1166663"
x="20.108339"
y="29.633333" />
</g>
<g
aria-label="svelte"
id="text24132"
style="font-size:13.7583px;line-height:1.25;font-family:Rubik;-inkscape-font-specification:Rubik;text-align:end;text-anchor:end;display:none;fill:#fefefe;stroke-width:0.529167"
transform="translate(-0.11704721,3.6255731)">
<path
d="m 22.379335,23.475161 q -0.770465,0 -1.320797,-0.192616 -0.550332,-0.192617 -0.894289,-0.467783 -0.343958,-0.275166 -0.522815,-0.550332 -0.1651,-0.275166 -0.178858,-0.440265 -0.01376,-0.151341 0.09631,-0.233891 0.110066,-0.08255 0.220133,-0.08255 h 0.605365 q 0.08255,0 0.123824,0.02752 0.05503,0.01376 0.137583,0.110066 0.178858,0.192616 0.398991,0.385232 0.220133,0.192617 0.536574,0.316441 0.330199,0.123825 0.81174,0.123825 0.701673,0 1.155697,-0.261408 0.454024,-0.275166 0.454024,-0.797981 0,-0.343958 -0.192617,-0.550332 -0.178858,-0.206375 -0.660398,-0.371474 -0.467782,-0.1651 -1.29328,-0.343958 -0.825498,-0.192616 -1.307039,-0.467782 -0.48154,-0.288924 -0.687915,-0.674157 -0.206374,-0.39899 -0.206374,-0.894289 0,-0.509057 0.302682,-0.976839 0.302683,-0.481541 0.880532,-0.784223 0.591607,-0.302683 1.472138,-0.302683 0.715431,0 1.224488,0.178858 0.509057,0.178858 0.839257,0.454024 0.330199,0.261407 0.495298,0.522815 0.1651,0.261408 0.178858,0.440266 0.01376,0.137583 -0.08255,0.233891 -0.09631,0.08255 -0.220133,0.08255 h -0.577849 q -0.09631,0 -0.165099,-0.04127 -0.05503,-0.04127 -0.110067,-0.09631 -0.137583,-0.178858 -0.330199,-0.357716 -0.178858,-0.178858 -0.481541,-0.288925 -0.288924,-0.123824 -0.770464,-0.123824 -0.687915,0 -1.031873,0.288924 -0.343957,0.288924 -0.343957,0.72919 0,0.261408 0.151341,0.467782 0.151341,0.206375 0.577849,0.371474 0.426507,0.1651 1.265763,0.357716 0.908048,0.178858 1.430863,0.481541 0.522816,0.302682 0.742949,0.701673 0.220132,0.398991 0.220132,0.921806 0,0.577849 -0.343957,1.059389 -0.330199,0.481541 -0.990598,0.770465 -0.64664,0.275166 -1.609721,0.275166 z"
id="path977"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 29.165596,23.337578 q -0.206374,0 -0.330199,-0.09631 -0.110066,-0.09631 -0.178858,-0.24765 l -2.545285,-6.383851 q -0.02752,-0.06879 -0.02752,-0.137583 0,-0.123824 0.08255,-0.206374 0.08255,-0.08255 0.206375,-0.08255 h 0.660398 q 0.151341,0 0.233891,0.08255 0.08255,0.08255 0.09631,0.151341 l 2.10502,5.42077 2.091262,-5.42077 q 0.02752,-0.06879 0.09631,-0.151341 0.08255,-0.08255 0.233891,-0.08255 h 0.674156 q 0.110067,0 0.192617,0.08255 0.09631,0.08255 0.09631,0.206374 0,0.06879 -0.02752,0.137583 L 30.26626,22.99362 q -0.05503,0.151342 -0.178858,0.24765 -0.110066,0.09631 -0.330199,0.09631 z"
id="path979"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 36.889984,23.475161 q -1.417105,0 -2.256361,-0.866773 -0.839257,-0.880531 -0.921807,-2.393944 -0.01376,-0.178858 -0.01376,-0.454024 0,-0.288924 0.01376,-0.467782 0.05503,-0.97684 0.454024,-1.70603 0.398991,-0.742948 1.086906,-1.141938 0.701673,-0.398991 1.637238,-0.398991 1.045631,0 1.747304,0.440266 0.715431,0.440265 1.086906,1.252005 0.371474,0.811739 0.371474,1.898645 v 0.233891 q 0,0.151342 -0.09631,0.233891 -0.08255,0.08255 -0.220133,0.08255 h -4.760372 q 0,0.01376 0,0.05503 0,0.04128 0,0.06879 0.02752,0.56409 0.247649,1.059389 0.220133,0.48154 0.632882,0.784223 0.412749,0.302683 0.990598,0.302683 0.495299,0 0.825498,-0.151342 0.330199,-0.151341 0.536573,-0.330199 0.206375,-0.192616 0.275166,-0.288924 0.123825,-0.178858 0.192617,-0.206375 0.06879,-0.04127 0.220132,-0.04127 h 0.660399 q 0.137583,0 0.220133,0.08255 0.09631,0.06879 0.08255,0.206375 -0.01376,0.206374 -0.220132,0.509057 -0.206375,0.288924 -0.591607,0.577848 -0.385233,0.288925 -0.935565,0.481541 -0.550332,0.178858 -1.265763,0.178858 z m -1.871129,-4.237557 h 3.769774 v -0.04127 q 0,-0.619124 -0.233891,-1.100664 -0.220133,-0.481541 -0.64664,-0.756707 -0.426507,-0.288924 -1.018114,-0.288924 -0.591607,0 -1.018114,0.288924 -0.412749,0.275166 -0.632882,0.756707 -0.220133,0.48154 -0.220133,1.100664 z"
id="path981"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 42.076855,23.337578 q -0.137583,0 -0.233891,-0.08255 -0.08255,-0.09631 -0.08255,-0.233891 v -9.135511 q 0,-0.137583 0.08255,-0.220133 0.09631,-0.09631 0.233891,-0.09631 h 0.64664 q 0.151342,0 0.233891,0.09631 0.08255,0.08255 0.08255,0.220133 v 9.135511 q 0,0.137583 -0.08255,0.233891 -0.08255,0.08255 -0.233891,0.08255 z"
id="path983"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 47.882832,23.337578 q -0.770465,0 -1.252006,-0.288924 -0.48154,-0.302683 -0.701673,-0.839257 -0.220133,-0.550332 -0.220133,-1.29328 v -3.632191 h -1.073147 q -0.137583,0 -0.233891,-0.08255 -0.08255,-0.09631 -0.08255,-0.233891 v -0.467782 q 0,-0.137583 0.08255,-0.220133 0.09631,-0.09631 0.233891,-0.09631 h 1.073147 v -2.297636 q 0,-0.137583 0.08255,-0.220133 0.09631,-0.09631 0.233891,-0.09631 h 0.64664 q 0.137583,0 0.220133,0.09631 0.09631,0.08255 0.09631,0.220133 v 2.297636 h 1.706029 q 0.137583,0 0.220133,0.09631 0.09631,0.08255 0.09631,0.220133 v 0.467782 q 0,0.137583 -0.09631,0.233891 -0.08255,0.08255 -0.220133,0.08255 h -1.706029 v 3.535883 q 0,0.64664 0.220133,1.018114 0.220133,0.371474 0.784223,0.371474 h 0.839256 q 0.137583,0 0.220133,0.09631 0.09631,0.08255 0.09631,0.220133 v 0.495299 q 0,0.137583 -0.09631,0.233891 -0.08255,0.08255 -0.220133,0.08255 z"
id="path985"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 53.317345,23.475161 q -1.417105,0 -2.256361,-0.866773 -0.839256,-0.880531 -0.921806,-2.393944 -0.01376,-0.178858 -0.01376,-0.454024 0,-0.288924 0.01376,-0.467782 0.05503,-0.97684 0.454024,-1.70603 0.398991,-0.742948 1.086906,-1.141938 0.701673,-0.398991 1.637237,-0.398991 1.045631,0 1.747304,0.440266 0.715432,0.440265 1.086906,1.252005 0.371474,0.811739 0.371474,1.898645 v 0.233891 q 0,0.151342 -0.09631,0.233891 -0.08255,0.08255 -0.220133,0.08255 h -4.760372 q 0,0.01376 0,0.05503 0,0.04128 0,0.06879 0.02752,0.56409 0.24765,1.059389 0.220133,0.48154 0.632882,0.784223 0.412749,0.302683 0.990597,0.302683 0.495299,0 0.825498,-0.151342 0.330199,-0.151341 0.536574,-0.330199 0.206374,-0.192616 0.275166,-0.288924 0.123825,-0.178858 0.192616,-0.206375 0.06879,-0.04127 0.220133,-0.04127 h 0.660398 q 0.137583,0 0.220133,0.08255 0.09631,0.06879 0.08255,0.206375 -0.01376,0.206374 -0.220133,0.509057 -0.206374,0.288924 -0.591607,0.577848 -0.385232,0.288925 -0.935564,0.481541 -0.550332,0.178858 -1.265764,0.178858 z m -1.871129,-4.237557 h 3.769775 v -0.04127 q 0,-0.619124 -0.233891,-1.100664 -0.220133,-0.481541 -0.646641,-0.756707 -0.426507,-0.288924 -1.018114,-0.288924 -0.591607,0 -1.018114,0.288924 -0.412749,0.275166 -0.632882,0.756707 -0.220133,0.48154 -0.220133,1.100664 z"
id="path987"
style="fill:#ffffff;fill-opacity:1" />
</g>
</g>
<g
id="g975"
style="display:inline">
<g
id="g4370"
style="display:inline"
transform="translate(-1.0583342)">
<rect
style="display:inline;opacity:1;fill:#ff0d2f;fill-opacity:1;stroke:#ff0d2f;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000;stop-opacity:1"
id="rect26826"
width="46.566666"
height="46.566669"
x="3.1750009"
y="19.049999" />
<rect
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#fffdff;stroke-width:1.05833;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000;stop-opacity:1"
id="rect52610"
width="38.100002"
height="2.116667"
x="11.641667"
y="38.099998" />
</g>
<g
aria-label="swaf"
id="text15483"
style="font-size:16.9333px;line-height:1.25;font-family:Rubik;-inkscape-font-specification:Rubik;text-align:end;text-anchor:end;display:none;fill:#fefefe;stroke-width:0.529167"
transform="translate(-5.0785949,7.0535064)">
<path
d="m 19.182091,28.269415 q -0.948264,0 -1.625596,-0.237066 -0.677332,-0.237066 -1.100665,-0.575732 -0.423332,-0.338666 -0.643465,-0.677332 -0.2032,-0.338666 -0.220133,-0.541866 -0.01693,-0.186266 0.118533,-0.287866 0.135466,-0.1016 0.270933,-0.1016 h 0.745065 q 0.1016,0 0.1524,0.03387 0.06773,0.01693 0.169333,0.135466 0.220132,0.237067 0.491065,0.474133 0.270933,0.237066 0.660399,0.389466 0.406399,0.152399 0.999065,0.152399 0.863598,0 1.422397,-0.321732 0.558799,-0.338666 0.558799,-0.982132 0,-0.423332 -0.237066,-0.677332 -0.220133,-0.253999 -0.812799,-0.457199 -0.575732,-0.203199 -1.59173,-0.423332 -1.015998,-0.237066 -1.608664,-0.575732 -0.592665,-0.3556 -0.846665,-0.829732 -0.253999,-0.491066 -0.253999,-1.100665 0,-0.626532 0.372533,-1.202264 0.372532,-0.592665 1.083731,-0.965198 0.728132,-0.372533 1.811863,-0.372533 0.880531,0 1.507064,0.220133 0.626532,0.220133 1.032931,0.558799 0.406399,0.321733 0.609599,0.643466 0.203199,0.321732 0.220133,0.541865 0.01693,0.169333 -0.1016,0.287866 -0.118533,0.1016 -0.270933,0.1016 H 21.38342 q -0.118533,0 -0.203199,-0.0508 -0.06773,-0.0508 -0.135467,-0.118533 -0.169333,-0.220133 -0.406399,-0.440266 -0.220133,-0.220133 -0.592665,-0.355599 -0.3556,-0.1524 -0.948265,-0.1524 -0.846665,0 -1.269998,0.3556 -0.423332,0.355599 -0.423332,0.897464 0,0.321733 0.186266,0.575733 0.186266,0.253999 0.711199,0.457199 0.524932,0.203199 1.557863,0.440266 1.117598,0.220132 1.761064,0.592665 0.643465,0.372533 0.914398,0.863598 0.270933,0.491066 0.270933,1.134531 0,0.711199 -0.423333,1.303865 -0.406399,0.592665 -1.219198,0.948264 -0.795865,0.338666 -1.981196,0.338666 z"
id="path841"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 26.874013,28.100082 q -0.220133,0 -0.338666,-0.101599 -0.118533,-0.118534 -0.2032,-0.321733 l -2.404528,-7.857051 q -0.03387,-0.08467 -0.03387,-0.169333 0,-0.1524 0.1016,-0.254 0.118533,-0.1016 0.253999,-0.1016 h 0.745065 q 0.186267,0 0.287867,0.1016 0.101599,0.1016 0.135466,0.186266 l 1.879596,6.383855 2.015063,-6.316121 q 0.03387,-0.118533 0.135466,-0.237067 0.118533,-0.118533 0.338666,-0.118533 h 0.575733 q 0.220132,0 0.338666,0.118533 0.118533,0.118534 0.135466,0.237067 l 2.015063,6.316121 1.879596,-6.383855 q 0.01693,-0.08467 0.118533,-0.186266 0.1016,-0.1016 0.287866,-0.1016 h 0.745065 q 0.1524,0 0.254,0.1016 0.1016,0.1016 0.1016,0.254 0,0.08467 -0.03387,0.169333 l -2.404529,7.857051 q -0.0508,0.203199 -0.186266,0.321733 -0.118533,0.101599 -0.355599,0.101599 h -0.660399 q -0.220133,0 -0.372532,-0.101599 -0.135467,-0.118534 -0.186267,-0.321733 L 30.0744,21.614628 28.110138,27.67675 q -0.06773,0.203199 -0.2032,0.321733 -0.135466,0.101599 -0.372533,0.101599 z"
id="path843"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 40.316868,28.269415 q -0.846665,0 -1.540931,-0.338666 -0.694265,-0.338666 -1.117597,-0.914398 -0.423333,-0.575732 -0.423333,-1.303864 0,-1.168398 0.948265,-1.862663 0.948265,-0.694265 2.472262,-0.914398 l 2.523061,-0.355599 v -0.491066 q 0,-0.812799 -0.474132,-1.269998 -0.457199,-0.457199 -1.507064,-0.457199 -0.761998,0 -1.236131,0.3048 -0.457199,0.304799 -0.643465,0.778931 -0.1016,0.254 -0.355599,0.254 h -0.761999 q -0.186266,0 -0.287866,-0.1016 -0.08467,-0.118533 -0.08467,-0.270933 0,-0.253999 0.186266,-0.626532 0.203199,-0.372532 0.609599,-0.728132 0.406399,-0.355599 1.032931,-0.592665 0.643465,-0.254 1.557864,-0.254 1.015998,0 1.710263,0.270933 0.694265,0.254 1.083731,0.694265 0.406399,0.440266 0.575732,0.999065 0.186267,0.558799 0.186267,1.134531 v 5.486389 q 0,0.169333 -0.118534,0.287867 -0.101599,0.101599 -0.270932,0.101599 h -0.778932 q -0.186266,0 -0.287866,-0.101599 -0.1016,-0.118534 -0.1016,-0.287867 v -0.728131 q -0.220133,0.304799 -0.592666,0.609598 -0.372532,0.287866 -0.931331,0.491066 -0.558799,0.186266 -1.371597,0.186266 z m 0.355599,-1.269997 q 0.694265,0 1.269997,-0.287866 0.575733,-0.3048 0.897465,-0.931332 0.338666,-0.626532 0.338666,-1.574797 v -0.474132 l -1.964262,0.287866 q -1.202265,0.169333 -1.811864,0.575732 -0.609598,0.389466 -0.609598,0.999065 0,0.474132 0.270932,0.795865 0.287867,0.304799 0.711199,0.457199 0.440266,0.1524 0.897465,0.1524 z"
id="path845"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 48.397167,28.100082 q -0.169333,0 -0.287866,-0.101599 -0.1016,-0.118534 -0.1016,-0.287867 V 20.64943 h -1.43933 q -0.169333,0 -0.287866,-0.101599 -0.1016,-0.118534 -0.1016,-0.287867 v -0.575732 q 0,-0.169333 0.1016,-0.270933 0.118533,-0.118533 0.287866,-0.118533 h 1.43933 v -0.846665 q 0,-0.863598 0.287867,-1.507063 0.287866,-0.660399 0.914398,-1.015998 0.643465,-0.3556 1.710263,-0.3556 h 1.015998 q 0.169333,0 0.270933,0.118533 0.1016,0.1016 0.1016,0.270933 v 0.575732 q 0,0.169333 -0.1016,0.287867 -0.1016,0.101599 -0.270933,0.101599 h -0.982131 q -0.795865,0 -1.083732,0.423333 -0.287866,0.406399 -0.287866,1.185331 v 0.761998 h 2.184396 q 0.169333,0 0.270933,0.118533 0.1016,0.1016 0.1016,0.270933 v 0.575732 q 0,0.169333 -0.1016,0.287867 -0.1016,0.101599 -0.270933,0.101599 h -2.184396 v 7.061186 q 0,0.169333 -0.118533,0.287867 -0.1016,0.101599 -0.270933,0.101599 z"
id="path847"
style="fill:#ffffff;fill-opacity:1" />
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,3 +0,0 @@
{
"type": "commonjs"
}

View File

@ -1 +0,0 @@
@import "data-table";

View File

@ -1,81 +0,0 @@
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5iU1EQVg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5jU1EQVg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5tU1E.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8cceyI9tScg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8ccezI9tScg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8cce9I9s.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5iU1EQVg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5jU1EQVg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5tU1E.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@ -1,139 +0,0 @@
@import "vars";
@mixin darkMode() {
@media (prefers-color-scheme: dark) {
@content;
}
}
@mixin surface($shadowStrength: 0) {
background-color: var(--surface);
color: var(--on-surface);
a {
color: var(--primary-on-surface);
&:hover {
color: var(--primary-light-on-surface);
}
}
:global(a) {
color: var(--primary-on-surface);
&:hover {
color: var(--primary-light-on-surface);
}
}
// Buttons
button, .button {
&:not(.bold) {
--background-color: var(--surface);
&:hover::after {
--background-color: var(--on-surface);
:global(&) {
--background-color: var(--on-surface);
}
}
}
}
// States modifiers
.primary:not(.bold) {
--color: var(--primary-on-surface);
--background-color: var(--surface);
}
.info:not(.bold) {
--color: var(--info-on-surface);
--background-color: var(--surface);
}
.success:not(.bold) {
--color: var(--success-on-surface);
--background-color: var(--surface);
}
.warning:not(.bold) {
--color: var(--warning-on-surface);
--background-color: var(--surface);
}
.error:not(.bold), .danger:not(.bold) {
--color: var(--error-on-surface);
--background-color: var(--surface);
}
@if ($shadowStrength > 0) {
box-shadow: 0 #{$shadowStrength}px #{$shadowStrength}px #00000045;
}
}
@mixin subsurface($shadowStrength: 0) {
@include surface($shadowStrength);
background-color: var(--subsurface);
color: var(--on-subsurface);
}
// --- Responsivity ---
@mixin mobile-le {
@media (max-width: $mobileThreshold - 1px) {
@content;
}
}
@mixin mobile-ge {
@content;
}
@mixin medium-le {
@media (max-width: $desktopThreshold - 1px) {
@content;
}
}
@mixin medium-ge {
@media (min-width: $mobileThreshold) {
@content;
}
}
@mixin large-le {
@content;
}
@mixin large-ge {
@media (min-width: $desktopThreshold) {
@content;
}
}
@mixin container {
width: 100%;
max-width: 100%;
padding-left: 8px;
padding-right: 8px;
@include medium-ge {
margin-left: auto;
margin-right: auto;
padding-left: 16px;
padding-right: 16px;
}
@include large-ge {
width: $desktopThreshold;
}
}
@mixin fake-hide {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
}

View File

@ -1,116 +0,0 @@
//
// --- Color palette ---
//
$onLight: #222;
$onDark: #eee;
// Primary
$primary: #0046af;
$primaryLight: lighten($primary, 10%);
$onPrimary: $onDark;
$primaryOnBackground: $primary;
$primaryLightOnBackground: $primaryLight;
$primaryOnSurface: $primary;
$primaryLightOnSurface: $primaryLight;
$primaryDarkMode: #0054c9;
$primaryLightDarkMode: lighten($primaryDarkMode, 23%);
$onPrimaryDarkMode: $onDark;
$primaryOnBackgroundDarkMode: lighten($primaryDarkMode, 20%);
$primaryLightOnBackgroundDarkMode: $primaryLightDarkMode;
$primaryOnSurfaceDarkMode: lighten($primaryDarkMode, 29%);
$primaryLightOnSurfaceDarkMode: lighten($primaryOnSurfaceDarkMode, 15%);
// Secondary
$secondary: #f21170;
$onSecondary: $onLight;
$secondaryDarkMode: $secondary;
$onSecondaryDarkMode: $onSecondary;
// Background
$backgroundBase: #eee;
$background: mix($backgroundBase, $primary, 98%);
$onBackground: $onLight;
$backgroundBaseDarkMode: #222;
$backgroundDarkMode: mix($backgroundBaseDarkMode, $primaryDarkMode, 98%);
$onBackgroundDarkMode: $onDark;
// Surface
$surface: lighten($background, 6%);
$onSurface: $onLight;
$surfaceDarkMode: darken($backgroundDarkMode, 4.5%);
$onSurfaceDarkMode: $onDark;
// Subsurface
$subsurface: darken($surface, 3%);
$onSubsurface: $onLight;
$subsurfaceDarkMode: darken($surfaceDarkMode, 3%);
$onSubsurfaceDarkMode: $onDark;
// Input
$input: darken($surface, 5%);
$onInput: $onLight;
$inputDarkMode: darken($surfaceDarkMode, 5%);
$onInputDarkMode: $onDark;
//
// --- Layout ---
//
$header: $surface;
$headerDarkMode: $surfaceDarkMode;
$headerContainer: true;
$headerHeight: 72px;
$footer: transparent;
//
// --- State palette ---
//
$info: #4499ff;
$onInfo: darken($info, 50%);
$infoOnBackground: darken($info, 20%);
$infoOnSurface: darken($info, 20%);
$infoDarkMode: darken($info, 40%);
$onInfoDarkMode: lighten($info, 20%);
$infoOnBackgroundDarkMode: $info;
$infoOnSurfaceDarkMode: $info;
$success: #55ff55;
$onSuccess: darken($success, 45%);
$successOnBackground: darken($success, 45%);
$successOnSurface: darken($success, 45%);
$successDarkMode: darken($success, 45%);
$onSuccessDarkMode: lighten($success, 20%);
$successOnBackgroundDarkMode: $success;
$successOnSurfaceDarkMode: $success;
$warning: #ffcc00;
$onWarning: darken($warning, 30%);
$warningOnBackground: darken($warning, 25%);
$warningOnSurface: darken($warning, 25%);
$warningDarkMode: darken($warning, 30%);
$onWarningDarkMode: lighten($warning, 20%);
$warningOnBackgroundDarkMode: $warning;
$warningOnSurfaceDarkMode: $warning;
$error: #ff0000;
$onError: darken($error, 40%);
$errorOnBackground: darken($error, 10%);
$errorOnSurface: darken($error, 10%);
$errorDarkMode: darken($error, 30%);
$onErrorDarkMode: lighten($error, 20%);
$errorOnBackgroundDarkMode: lighten($error, 15%);
$errorOnSurfaceDarkMode: lighten($error, 3%);
//
// --- Responsivity ---
//
$mobileThreshold: 632px;
$desktopThreshold: 940px;

View File

@ -1,276 +0,0 @@
@import "vars";
@import "helpers";
@import "fonts";
@import "../../../node_modules/normalize.css/normalize";
// --- Css variables, dark mode ---
:root {
// Primary
--primary: #{$primary};
--primary-light: #{$primaryLight};
--on-primary: #{$onPrimary};
--primary-on-background: #{$primaryOnBackground};
--primary-light-on-background: #{$primaryLightOnBackground};
--primary-on-surface: #{$primaryOnSurface};
--primary-light-on-surface: #{$primaryLightOnSurface};
// Secondary
--secondary: #{$secondary};
--on-secondary: #{$onSecondary};
// Background
--background: #{$background};
--on-background: #{$onBackground};
// Surface
--surface: #{$surface};
--on-surface: #{$onSurface};
// Subsurface
--subsurface: #{$subsurface};
--on-subsurface: #{$onSubsurface};
// Input
--input: #{$input};
--on-input: #{$onInput};
// States
--info: #{$info};
--success: #{$success};
--warning: #{$warning};
--error: #{$error};
// States text
--on-info: #{$onInfo};
--on-success: #{$onSuccess};
--on-warning: #{$onWarning};
--on-error: #{$onError};
// States text on background
--info-on-background: #{$infoOnBackground};
--success-on-background: #{$successOnBackground};
--warning-on-background: #{$warningOnBackground};
--error-on-background: #{$errorOnBackground};
// States text on surface
--info-on-surface: #{$infoOnSurface};
--success-on-surface: #{$successOnSurface};
--warning-on-surface: #{$warningOnSurface};
--error-on-surface: #{$errorOnSurface};
@include darkMode {
// Primary
--primary: #{$primaryDarkMode};
--primary-light: #{$primaryLightDarkMode};
--on-primary: #{$onPrimaryDarkMode};
--primary-on-background: #{$primaryOnBackgroundDarkMode};
--primary-light-on-background: #{$primaryLightOnBackgroundDarkMode};
--primary-on-surface: #{$primaryOnSurfaceDarkMode};
--primary-light-on-surface: #{$primaryLightOnSurfaceDarkMode};
// Secondary
--secondary: #{$secondaryDarkMode};
--on-secondary: #{$onSecondaryDarkMode};
// Background
--background: #{$backgroundDarkMode};
--on-background: #{$onBackgroundDarkMode};
// Surface
--surface: #{$surfaceDarkMode};
--on-surface: #{$onSurfaceDarkMode};
// Subsurface
--subsurface: #{$subsurfaceDarkMode};
--on-subsurface: #{$onSubsurfaceDarkMode};
// Input
--input: #{$inputDarkMode};
--on-input: #{$onInputDarkMode};
// States
--info: #{$infoDarkMode};
--success: #{$successDarkMode};
--warning: #{$warningDarkMode};
--error: #{$errorDarkMode};
// States text
--on-info: #{$onInfoDarkMode};
--on-success: #{$onSuccessDarkMode};
--on-warning: #{$onWarningDarkMode};
--on-error: #{$onErrorDarkMode};
// States text on background
--info-on-background: #{$infoOnBackgroundDarkMode};
--success-on-background: #{$successOnBackgroundDarkMode};
--warning-on-background: #{$warningOnBackgroundDarkMode};
--error-on-background: #{$errorOnBackgroundDarkMode};
// States text on surface
--info-on-surface: #{$infoOnSurfaceDarkMode};
--success-on-surface: #{$successOnSurfaceDarkMode};
--warning-on-surface: #{$warningOnSurfaceDarkMode};
--error-on-surface: #{$errorOnSurfaceDarkMode};
}
--color: var(--on-background);
--background-color: var(--background);
}
:focus-visible,
button:focus-visible,
[type="button"]:focus-visible,
[type="reset"]:focus-visible,
[type="submit"]:focus-visible {
outline: 3px solid var(--primary-light);
outline-offset: 2px;
}
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
color: var(--color);
background-color: var(--background-color);
a {
color: var(--primary-on-background);
&:hover {
color: var(--primary-light-on-background);
}
}
}
h1 {
text-align: center;
}
a {
text-decoration: none;
.icon.lucide-external-link {
--icon-size: 16px;
margin-left: 4px;
margin-top: -3px;
}
}
ul {
list-style-type: '- ';
}
hr {
margin: 0;
border: 0;
border-top: 1px solid var(--on-background);
opacity: 0.2;
}
.primary, .bold {
--color: var(--primary-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-primary);
--background-color: var(--primary);
}
}
.info {
--color: var(--info-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-info);
--background-color: var(--info);
}
}
.success {
--color: var(--success-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-success);
--background-color: var(--success);
}
}
.warning {
--color: var(--warning-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-warning);
--background-color: var(--warning);
}
}
.error, .danger {
--color: var(--error-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-error);
--background-color: var(--error);
}
}
button, .button {
position: relative;
display: inline-flex;
margin: 8px;
padding: 12px 16px;
border: 1px solid var(--color);
color: var(--color);
background-color: var(--background-color);
cursor: pointer;
text-transform: uppercase;
font-size: 16px;
line-height: 16px;
font-weight: bolder;
border-radius: 5px;
overflow: hidden;
&.bold {
border: 0;
}
.icon {
--icon-size: 16px;
margin-right: 8px;
}
.icon.last {
margin-right: 0;
margin-left: 8px;
}
&:hover::after:not([disabled]) {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--on-background);
opacity: 0.2;
}
&[disabled] {
position: relative;
cursor: not-allowed;
opacity: 0.1;
}
}

View File

@ -1,71 +0,0 @@
.data-table {
width: 100%;
text-align: left;
border-collapse: collapse;
th, td {
padding: 8px;
}
th {
border-bottom: 1px solid #39434a;
white-space: nowrap;
}
tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.03);
}
tr:hover {
background-color: rgba(255, 255, 255, 0.09);
}
thead tr:hover {
background-color: transparent;
}
tbody td.actions {
display: flex;
flex-direction: row;
button {
padding: 8px;
.icon {
margin: 0 !important;
}
.tip {
display: none;
}
}
}
thead th.col-grow {
width: 100%;
}
tbody td.col-grow-cell {
> * {
display: flex;
flex-direction: row;
> * {
width: 0;
flex-grow: 1;
white-space: nowrap;
text-overflow: ellipsis;
}
* {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.data-table-container {
overflow-x: auto;
max-width: 100%;
}

View File

@ -1,26 +0,0 @@
@import "vars";
@import "helpers";
@import "base";
@import "panel";
@import "components";
html {
height: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100%;
margin: 0;
font-family: "Nunito Sans", sans-serif;
font-size: 16px;
background-color: var(--background);
color: var(--on-background);
> * {
flex-shrink: 0;
}
}

View File

@ -1,53 +0,0 @@
@import "vars";
@import "helpers";
.panel {
position: relative;
margin: 16px 0 48px;
padding: 8px;
border-radius: 5px;
@include surface;
.panel {
@include subsurface;
}
p {
margin: 16px 8px;
}
> .icon:first-child {
--icon-size: 24px;
position: absolute;
opacity: 0.2;
top: 8px;
left: 8px;
}
> h1, > h2, > h3, > h4, > h5, > h6 {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
text-align: center;
margin-top: 4px;
font-size: 24px;
line-height: 1;
.icon {
--icon-size: 24px;
margin: 0 16px 0 0;
opacity: 0.2;
}
&::after {
content: "";
flex: 1;
margin: 0 16px;
height: 0;
border-bottom: 1px solid var(--on-surface);
opacity: 0.2;
}
}
}

View File

@ -1,46 +0,0 @@
@import "vars";
@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: var(--on-surface);
opacity: 0;
transition: opacity ease-out 100ms, visibility step-end 150ms;
transition-delay: 0ms;
background-color: var(--surface);
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;
}
}
}

View File

@ -1,38 +0,0 @@
export default class WebsocketClient {
/**
* @param websocketUrl
* @param listener
* @param reconnectOnCloseAfter time to reconnect after connection fail in ms. -1 to not reconnect automatically.
* @param checkFunction
*/
public constructor(
private readonly websocketUrl: string,
private readonly listener: (websocket: WebSocket, e: MessageEvent) => void,
private readonly reconnectOnCloseAfter: number = 1000,
) {
}
public run(): void {
const websocket = new WebSocket(this.websocketUrl);
websocket.onopen = () => {
console.debug('Websocket connected');
websocket.send(document.cookie);
};
websocket.onmessage = (e) => {
this.listener(websocket, e);
};
websocket.onerror = (e) => {
console.error('Websocket error', e);
};
websocket.onclose = (e) => {
console.debug('Websocket closed', e.code, e.reason);
if (this.reconnectOnCloseAfter >= 0) {
setTimeout(() => {
this.run();
}, this.reconnectOnCloseAfter);
}
};
}
}

View File

@ -1,25 +0,0 @@
export function dateToDatetimeLocal(date: Date): string {
function ten(i: number) {
return (i < 10 ? '0' : '') + i;
}
const YYYY = date.getFullYear();
const MM = ten(date.getMonth() + 1);
const DD = ten(date.getDate());
const HH = ten(date.getHours());
const II = ten(date.getMinutes());
const SS = ten(date.getSeconds());
return YYYY + '-' + MM + '-' + DD + 'T' +
HH + ':' + II + ':' + SS;
}
export const dateToIsoString = (function (BST) {
// BST should not be present as UTC time
if (new Date(BST).toISOString().slice(0, 16) === BST) {
return (date: Date): string => {
return new Date(date.getTime() + date.getTimezoneOffset() * 60000)
.toISOString();
};
} else {
return (date: Date) => date.toISOString();
}
}('2006-06-06T06:06'));

View File

@ -1,17 +0,0 @@
import {createIcons, icons} from "lucide";
import {toLucideIconsPascalCase} from "../../common/StringUtils.js";
let hasAlreadyReplacedIcons = false;
export function replaceIcons(once: boolean): void {
if (!once || !hasAlreadyReplacedIcons) {
console.log('Create icons...');
createIcons({icons});
hasAlreadyReplacedIcons = true;
}
}
export function isLucideIcon(iconName: string): boolean {
return Object.keys(icons).indexOf(toLucideIconsPascalCase(iconName)) >= 0;
}

View File

@ -1,3 +0,0 @@
import {writable} from "svelte/store";
export const locals = writable<Record<string, unknown>>({});

View File

@ -1,6 +0,0 @@
{
"extends": "./tsconfig.json",
"include": [
"./**/*"
]
}

View File

@ -1,27 +0,0 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"baseUrl": "../../../intermediates/assets",
"rootDir": "../../../intermediates/assets/ts-source",
"sourceRoot": "../../../intermediates/assets/ts-source",
"outDir": "../../../intermediates/assets/ts",
"declaration": false,
"typeRoots": [],
"resolveJsonModule": false,
"lib": [
"es2020",
"DOM"
]
},
"include": [
"../../../intermediates/assets/ts-source/**/*"
],
"references": [
{
"path": "../../common"
}
]
}

View File

@ -1,49 +0,0 @@
<script>
import Icon from "./utils/Icon.svelte";
</script>
<p style="display: flex; flex-direction: column; align-items: start">
<button>Default button</button>
<button class="bold">Default bold button</button>
<button class="primary">
<Icon name="square"/>
Primary button
</button>
<button class="primary bold">
<Icon name="square"/>
Primary bold button
</button>
<button class="success">
<Icon name="check"/>
Success button
</button>
<button class="success bold">
<Icon name="check"/>
Success bold button
</button>
<button class="info">
<Icon name="info"/>
Info button
</button>
<button class="info bold">
<Icon name="info"/>
Info bold button
</button>
<button class="warning">
<Icon name="alert-triangle"/>
Warning button
</button>
<button class="warning bold">
<Icon name="alert-triangle"/>
Warning bold button
</button>
<button class="error">
<Icon name="x-circle"/>
Error button
</button>
<button class="error bold">
<Icon name="x-circle"/>
Error bold button
</button>
</p>

View File

@ -1,35 +0,0 @@
<script>
import {locals} from "../../../ts/stores";
import Message from "../../components/Message.svelte";
import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import Icon from "../../utils/Icon.svelte";
let newName = '';
</script>
{#if hasRoute('change-name')}
<section class="panel">
<h2><Icon name="key"/> Change name</h2>
{#if $locals.can_change_name}
<Form action={route('change-name')}
submitIcon="save" submitText="Change my name {newName.length > 0 ? 'to ' + newName : ''}"
confirm="Are you sure you want to change your name to {newName}?">
<Field type="text" name="name" icon="user" placeholder="New name" required bind:value={newName}/>
<Field type="checkbox" name="terms"
placeholder="I understand that I can only change my name once every {$locals.name_change_wait_period}"
required/>
<Field type="checkbox" name="terms2"
placeholder="I understand that my old name {$locals.user.name} will become available for anyone to take"
required/>
</Form>
{:else}
<Message type="info" content="You will be able to change your name in {$locals.can_change_name_in}" sticky discreet/>
{/if}
</section>
{/if}

View File

@ -1,34 +0,0 @@
<script>
import {locals} from "../../../ts/stores";
import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import Icon from "../../utils/Icon.svelte";
let removePasswordMode = false;
</script>
{#if hasRoute('remove-password', 'change-password')}
<section class="panel">
<h2><Icon name="key"/> {$locals.has_password ? 'Change' : 'Set'} password</h2>
{#if removePasswordMode}
<Form action={route('remove-password')}
submitIcon="trash" submitText="Remove password" submitClass="danger"
confirm="Are you sure you want to remove your password?">
<button type="button" on:click={() => removePasswordMode = false}>Go back</button>
</Form>
{:else}
<Form action={route('change-password')}
submitIcon="save" submitText="Set password">
{#if $locals.has_password}
<Field type="password" name="current_password" icon="key" placeholder="Current password"/>
<button type="button" on:click={() => removePasswordMode = true}>Forgot your password?</button>
{/if}
<Field type="password" name="new_password" icon="key" placeholder="New password" required/>
<Field type="password" name="new_password_confirmation" icon="key" placeholder="New password confirmation" required/>
</Form>
{/if}
</section>
{/if}

View File

@ -1,109 +0,0 @@
<script>
import {locals} from "../../../ts/stores";
import BaseTemplate from "../../templates/BaseTemplate.svelte";
import Message from "../../components/Message.svelte";
import NamePanel from "./NamePanel.svelte";
import PasswordPanel from "./PasswordPanel.svelte";
import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import Icon from "../../utils/Icon.svelte";
const mainEmail = $locals.main_email?.email;
const personalInfoFields = $locals.user_personal_info_fields || [];
const emails = $locals.emails || [];
</script>
<BaseTemplate title="Account" description="Manage your account settings and data.">
<section class="panel">
<h2>
<Icon name="user"/>
Personal information
</h2>
{#if $locals.display_email_warning && $locals.emails.length <= 0}
<Message type="warning" content="To avoid losing access to your account, please add an email address."/>
{/if}
{#each personalInfoFields as field}
<p>{field.name}: {field.value}</p>
{/each}
{#if mainEmail}
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
{/if}
</section>
{#if $locals.has_name_component}
<NamePanel/>
{/if}
{#if $locals.has_password_component}
<PasswordPanel/>
{/if}
<section class="panel">
<h2 id="emails">
<Icon name="shield"/>
Email addresses
</h2>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each emails as email}
{#if email.id === $locals.user.main_email_id}
<tr>
<td>Main</td>
<td>{email.email}</td>
<td></td>
</tr>
{/if}
{/each}
{#each emails as email}
{#if email.id !== $locals.user.main_email_id}
<tr>
<td>Secondary</td>
<td>{email.email}</td>
<td class="actions">
<Form action={route('set-main-email')} button
submitIcon="refresh-ccw" submitText="Set as main address"
submitClass="warning"
confirm="Are you sure you want to set {email.email} as your main address?">
<Field type="hidden" name="id" value={email.id}/>
</Form>
<Form action={route('remove-email')} button
submitIcon="trash" submitText="Remove" submitClass="danger"
confirm="Are you sure you want to delete {email.email}?">
<Field type="hidden" name="id" value={email.id}/>
</Form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if hasRoute('add-email')}
<Form action={route('add-email')} class="sub-panel"
submitIcon="plus" submitText="Add email address">
<h3>Add an email address:</h3>
<Field type="email" name="email" icon="at-sign" placeholder="Choose a safe email address"
hint="An email address we can use to identify you in case you lose access to your account"
required/>
</Form>
{/if}
</section>
</BaseTemplate>

View File

@ -1,99 +0,0 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import BaseTemplate from "../templates/BaseTemplate.svelte";
import Form from "../utils/Form.svelte";
import Field from "../utils/Field.svelte";
import Icon from "../utils/Icon.svelte";
import {hasRoute, route} from "../../../common/Routing";
let registerUsingMagicLink = $locals.flash.previousFormData?.[0]?.['auth_method'] !== 'password';
let loginUsingMagicLink = !$locals.flash.previousFormData?.[0]?.['password'];
let queryStr = '';
let previousUrl: string = $locals.previousUrl as string | undefined;
if ($locals.query?.redirect_uri) {
queryStr = '?' + new URLSearchParams({redirect_uri: $locals.query?.redirect_uri}).toString();
} else if (previousUrl) {
queryStr = '?' + new URLSearchParams({redirect_uri: previousUrl}).toString();
}
</script>
<BaseTemplate title="Authentication / Registration"
description="Join {$locals.app.name} and share your files!"
h1="Authentication and registration">
{#if hasRoute('login')}
<section class="panel">
<h2>
<Icon name="log-in"/>
Log in
</h2>
<Form action={route('login') + queryStr} submitText="Authenticate" submitIcon="log-in">
<Field type="text" name="identifier" value={$locals.query?.identifier} icon="at-sign"
hint={loginUsingMagicLink ? 'You will receive a magic link in your mailbox. Click on the link from any device to authenticate here.' : ''}
placeholder="Your email address or username" required/>
{#if $locals.hasPassword}
{#if loginUsingMagicLink}
<button on:click={() => loginUsingMagicLink=false} type="button">
<Icon name="key"/>
Use password
</button>
{:else}
<Field type="password" name="password" placeholder="Your password" icon="key" required/>
<button on:click={() => loginUsingMagicLink=true} type="button">
<Icon name="mail"/>
Use magic link
</button>
{/if}
{/if}
<Field type="checkbox" name="persist_session" icon="clock"
placeholder="Stay logged in on this computer."/>
</Form>
</section>
{/if}
{#if hasRoute('register')}
<section class="panel">
<h2>
<Icon name="user-plus"/>
Register
</h2>
<Form action={route('register') + queryStr} submitText="Register" submitIcon="check">
<Field type="hidden" name="auth_method" value={registerUsingMagicLink ? 'magic_link': 'password'}/>
{#if $locals.hasUsername}
<Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'} icon="user"
placeholder="Choose your username"
pattern="[0-9a-z_-]+" required/>
{/if}
{#if registerUsingMagicLink || !$locals.canRegisterWithPassword}
<Field type="email" name="identifier" icon="at-sign" placeholder="Your email address"
hint="You will receive a magic link in your mailbox. Click on the link from any device to register here."
required/>
{#if $locals.canRegisterWithPassword}
<button on:click={() => registerUsingMagicLink=false} type="button">
<Icon name="key"/>
Use password
</button>
{/if}
{:else}
<Field type="password" name="password" icon="key" placeholder="Choose a password" required/>
<Field type="password" name="password_confirmation" icon="key" placeholder="Confirm your password"
required/>
<button on:click={() => registerUsingMagicLink=true} type="button">
<Icon name="at-sign"/>
Use email address instead
</button>
{/if}
<Field type="checkbox" name="terms" icon="file-text" required>
I accept the <a href="/terms-of-services" target="_blank">Terms Of Services</a>.
</Field>
</Form>
</section>
{/if}
</BaseTemplate>

View File

@ -1,81 +0,0 @@
<script lang="ts">
import {locals} from "../../ts/stores";
import BaseTemplate from "../templates/BaseTemplate.svelte";
import Pagination from "../components/Pagination.svelte";
import Form from "../utils/Form.svelte";
import Field from "../utils/Field.svelte";
import Breadcrumb from "../components/Breadcrumb.svelte";
import {hasRoute, route} from "../../../common/Routing";
const accounts = $locals.accounts || [];
</script>
<style>
td.empty {
text-align: center;
}
</style>
<BaseTemplate title="{$locals.app.name} - Review accounts" h1={false}>
{#if hasRoute('backend')}
<Breadcrumb currentPageTitle="Accounts pending review" pages={[
{link: route('backend'), title:'Backend'},
]}/>
{/if}
<h1>Accounts pending review</h1>
<Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" />
<div class="panel data-table-container">
<table class="data-table">
<thead>
<tr>
<th class="shrink-col">#</th>
{#if $locals.has_user_name_component}
<th>Name</th>
{/if}
<th>Main email</th>
<th>Registered at</th>
<th class="shrink-col">Action</th>
</tr>
</thead>
<tbody>
{#each accounts as user}
<tr>
<td>{user.id}</td>
{#if $locals.has_user_name_component}
<td>{user.name}</td>
{/if}
<td>{user.mainEmailStr || 'No email'}</td>
<td><time datetime={user.created_at_iso}>{user.created_at_human} ago</time></td>
<td>
<div class="max-content">
{#if hasRoute('approve-account')}
<Form action={route('approve-account')}
submitIcon="check" submitText="Approve" submitClass="success">
<Field type="hidden" name="user_id" value={user.id}/>
</Form>
{/if}
{#if hasRoute('reject-account')}
<Form action={route('reject-account')}
submitIcon="trash" submitText="Reject" submitClass="danger"
confirm="This will irrevocably delete the {user.mainEmailStr || user.name || user.id} account.">
<Field type="hidden" name="user_id" value={user.id}/>
</Form>
{/if}
</div>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="empty">No account to review.</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" />
</BaseTemplate>

View File

@ -1,31 +0,0 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import BaseTemplate from "../templates/BaseTemplate.svelte";
import Breadcrumb from "../components/Breadcrumb.svelte";
import Icon from "../utils/Icon.svelte";
const menu = $locals.menu || [];
</script>
<BaseTemplate title="{$locals.app.name} backend" h1={false}>
<Breadcrumb currentPageTitle="Backend"/>
<h1>App administration</h1>
<div class="panel">
<nav>
<ul>
{#each menu as element}
<li>
<a href={element.link}>
{#if element.display_icon !== null}
<Icon name={element.display_icon}/>
{/if}
{element.display_string}
</a>
</li>
{/each}
</ul>
</nav>
</div>
</BaseTemplate>

View File

@ -1,41 +0,0 @@
<script lang="ts">
import Icon from "../utils/Icon.svelte";
export let currentPageTitle: string;
export let pages: { link: string, title: string }[] = [];
</script>
<style lang="scss">
ol {
display: flex;
flex-direction: row;
margin: 8px 0;
padding: 0 8px;
list-style: none;
overflow-x: auto;
border-radius: 5px;
li {
display: flex;
flex-direction: row;
white-space: nowrap;
align-items: center;
a, span {
display: block;
padding: 8px;
}
}
}
</style>
<nav aria-label="breadcrumb">
<ol class="breadcrumb panel">
{#each pages as page}
<li><a href={page.link}>{page.title}</a> <Icon name="chevron-right"/></li>
{/each}
<li class="active" aria-current="page"><span>{currentPageTitle}</span></li>
</ol>
</nav>

View File

@ -1,123 +0,0 @@
<script lang="ts">
import Icon from "../utils/Icon.svelte";
import { fade } from "svelte/transition";
export let title: string | undefined = undefined;
export let content: string;
export let buttonMode: boolean = false;
let contentNode: HTMLElement;
let copiedOverlay: HTMLElement;
function selectAll() {
const selection = window.getSelection();
if (contentNode && selection) {
selection.selectAllChildren(contentNode);
}
}
function copy() {
const selection = window.getSelection();
if (contentNode && selection) {
selectAll();
navigator.clipboard.writeText(contentNode.innerText);
showOverlay();
}
}
let showCopiedOverlay = false;
function showOverlay() {
showCopiedOverlay = true;
}
function releaseOverlay() {
showCopiedOverlay = false;
}
</script>
<style lang="scss">
@import "../../scss/helpers";
.copyable-text {
position: relative;
display: flex;
flex-direction: row;
margin: 8px;
padding: 0;
border-radius: 5px;
overflow: hidden;
.title {
padding: 8px;
}
.content {
width: 0;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
padding: 8px;
}
.copy-button {
margin: 0;
padding: 0;
border-radius: 0;
:global(.icon) {
--icon-size: 20px;
margin: 8px;
}
}
}
.button-mode-button {
position: relative;
}
.copied-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 8px;
text-align: center;
background-color: var(--success);
}
.content.hidden {
pointer-events: none;
overflow: hidden;
width: 0;
height: 0;
margin: 0;
padding: 0;
position: absolute;
}
</style>
{#if buttonMode}
<div class="content hidden" bind:this={contentNode} on:click={selectAll}>{content}</div>
<button class="bold button-mode-button" on:click={copy} title="{content}">
<Icon name="copy"/>
{#if showCopiedOverlay}
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}><Icon name="check"/></div>
{/if}
</button>
{:else}
<div class="copyable-text panel">
{#if title}
<div class="title">{title}</div>
{/if}
<div class="content" bind:this={contentNode} on:click={selectAll}>{content}</div>
<button class="bold copy-button" on:click={copy}><Icon name="copy"/></button>
{#if showCopiedOverlay}
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}>Copied!</div>
{/if}
</div>
{/if}

View File

@ -1,29 +0,0 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import Message from "./Message.svelte";
export let flashed = $locals.flash;
const displayedCategories = [
'success',
'info',
'warning',
'error',
'error-alert',
];
</script>
<style lang="scss">
.messages :global(.message:not(:last-child)) {
margin-bottom: 8px;
}
</style>
<div class="messages">
{#if flashed}
{#each Object.entries(flashed).filter(entry => displayedCategories.indexOf(entry[0]) >= 0) as [key, bag], i}
{#each bag as content}
<Message type={key} content={content}/>
{/each}
{/each}
{/if}
</div>

View File

@ -1,69 +0,0 @@
<script>
import {fade} from 'svelte/transition';
export let show = true;
export let size = '72px';
</script>
<style lang="scss">
.loader {
position: fixed;
z-index: 10000;
width: 100%;
height: 100%;
left: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
background: var(--background-color);
.parts {
--size: 72px;
position: relative;
width: var(--size);
height: var(--size);
.bg-circle {
opacity: 0.5;
}
.fg-arc {
border-top: 8px solid transparent;
border-left: 8px solid transparent;
border-bottom: 8px solid transparent;
animation: infinite linear 2s spin;
}
> * {
position: absolute;
width: 100%;
height: 100%;
border: 8px solid #fff;
border-radius: 100%;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
{#if show}
<div class="loader" style="--size: {size};" transition:fade={{duration: 50}}>
<div class="parts">
<div class="bg-circle"></div>
<div class="fg-arc"></div>
</div>
</div>
{/if}

View File

@ -1,123 +0,0 @@
<script>
import Icon from "../utils/Icon.svelte";
export let type;
export let content;
export let raw = false;
export let discreet = false;
export let sticky = false;
let icon = undefined;
switch (type) {
case 'success':
icon = 'check';
break;
case 'info':
icon = 'info';
break;
case 'warning':
icon = 'alert-triangle';
break;
case 'error':
icon = 'x-circle';
break;
case 'error-alert':
icon = 'alert-circle';
break;
}
let message;
function hide() {
message.remove();
}
</script>
<style lang="scss">
@import "../../scss/vars";
.message {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 16px;
border-radius: 5px;
:global(.icon) {
--icon-size: 24px;
margin-right: 8px;
}
&:not(&-discreet) {
background-color: rgba(255, 255, 255, 0.33);
&[data-type=info], &[data-type=question] {
color: var(--on-info);
background-color: var(--info);
}
&[data-type=success] {
color: var(--on-success);
background-color: var(--success);
}
&[data-type=warning] {
color: var(--on-warning);
background-color: var(--warning);
}
&[data-type=error], &[data-type=error-alert] {
color: var(--on-error);
background-color: var(--error);
}
}
&-discreet {
color: var(--on-surface);
.icon {
--icon-size: 20px;
}
}
.content {
flex-grow: 1;
}
button {
background-color: transparent;
color: inherit;
margin: 0;
padding: 0;
border: 0;
:global(.icon) {
margin: 0;
}
}
}
</style>
<div class="message" class:message-discreet={discreet} data-type="{type}" bind:this={message}>
{#if icon}
<Icon name={icon}/>
{/if}
<span class="content">
{#if raw}
{@html content}
{:else}
{content}
{/if}
</span>
{#if !sticky}
<button type="button" on:click={hide}>
<Icon name="x"/>
</button>
{/if}
</div>

View File

@ -1,126 +0,0 @@
<script>
import {onMount} from "svelte";
import Icon from "../utils/Icon.svelte";
let open = false;
let locked = false;
function stopPropagation(e) {
e.stopPropagation();
}
function openMenu() {
if (locked) return;
open = true;
}
function closeMenu() {
if (locked) return;
open = false;
}
function lock() {
locked = true;
window.requestAnimationFrame(() => {
locked = false;
});
}
let nav;
onMount(() => {
nav.querySelectorAll('ul li > a, ul li > form > button')
.forEach(el => {
el.addEventListener('focus', () => {
openMenu();
});
el.addEventListener('blur', () => {
closeMenu();
});
});
});
</script>
<style lang="scss">
@import "../../scss/vars";
@import "../../scss/helpers";
nav {
top: 0;
left: 0;
height: 100%;
padding: 16px;
font-size: 16px;
@include medium-le {
z-index: 1;
position: fixed;
padding: 16px;
@include surface(3);
transition: transform ease-out 150ms;
&:not(.open) {
transform: translateX(-100%);
}
}
ul {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
list-style: none;
@include large-ge {
flex-direction: row;
align-items: center;
}
}
}
button {
width: #{$headerHeight - 16px};
height: #{$headerHeight - 16px};
margin: 8px;
padding: 8px;
border: 0;
justify-content: center;
align-items: center;
background: var(--surface);
color: var(--on-surface);
border-radius: $headerHeight;
:global(.icon) {
--icon-size: 28px;
margin: 0;
}
@include large-ge {
display: none;
}
}
</style>
<svelte:window on:click={closeMenu}/>
<button on:click={openMenu} on:click={stopPropagation}
on:focus={openMenu} on:blur={closeMenu}
tabindex="0" aria-label="Toggle menu">
<Icon name="menu"/>
</button>
<nav class:open on:click={openMenu} on:click={stopPropagation} on:mousedown={lock} bind:this={nav}
aria-hidden={open ? 'false' : 'true'}>
<ul>
<slot/>
</ul>
</nav>

View File

@ -1,72 +0,0 @@
<script lang="ts">
import Icon from "../utils/Icon.svelte";
export let open: boolean = false;
let hovered = false;
function onMouseEnter() {
hovered = true;
}
function onMouseLeave() {
hovered = false;
}
</script>
<style lang="scss">
@import "../../scss/helpers";
ul {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
@include large-ge() {
ul:not(.open) {
display: none;
}
ul {
position: absolute;
top: calc(100% - 3px);
right: 0;
z-index: 1;
@include surface(3);
padding: 16px;
border-top: 3px solid #ffffff1c;
border-radius: 0 0 3px 3px;
}
}
.icon-container {
position: absolute;
z-index: 2;
left: 50%;
top: calc(100% - 8px);
transition: transform 50ms linear;
transform: translateX(-50%);
&.open {
transform: translateX(-50%) translateY(8px) rotateX(180deg);
}
}
@include medium-le {
.icon-container {
display: none;
}
}
</style>
<div class="icon-container" class:open={open || hovered}>
<Icon name="chevron-down"/>
</div>
<ul class:open={open || hovered} on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
<slot/>
</ul>

View File

@ -1,94 +0,0 @@
<script>
import Icon from "../utils/Icon.svelte";
import Form from "../utils/Form.svelte";
export let href;
export let icon;
export let text;
export let action = false;
export let hovered = false;
function onMouseEnter() {
hovered = true;
}
function onMouseLeave() {
hovered = false;
}
</script>
<style lang="scss">
@import "../../scss/helpers";
li {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
line-height: 1;
@mixin aHover {
background-color: rgba(0, 0, 0, 0.07);
@include darkMode {
background-color: rgba(255, 255, 255, 0.07);
}
}
a {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: auto;
padding: 8px;
white-space: nowrap;
border-radius: 3px;
text-transform: uppercase;
@include medium-le {
&:hover {
@include aHover;
}
}
}
@include large-ge {
&:hover > a {
@include aHover;
}
}
:global(form) {
width: 100%;
:global(button) {
display: flex;
width: 100%;
margin: 0;
padding: 8px;
}
}
:global(.icon) {
--icon-size: 16px;
margin-right: 8px;
}
}
</style>
<li on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
{#if action}
<Form action={href} submitIcon={icon} submitText={text}/>
{:else}
<a href={href}>
<Icon name={icon}/>
<span class="tip">{text}</span></a>
{/if}
<slot/>
</li>

View File

@ -1,88 +0,0 @@
<script lang="ts">
import {route} from "../../../common/Routing.js";
import {Pagination} from "../../../common/Pagination.js";
import Icon from "../utils/Icon.svelte";
export let pagination: string;
export let routeName: string;
export let contextSize: number;
if (typeof contextSize !== 'number') contextSize = parseInt(contextSize);
$: paginationObj = pagination ? Pagination.deserialize(pagination) : null;
</script>
<style lang="scss">
@import "../../scss/helpers";
.pagination {
ul {
display: flex;
flex-direction: row;
width: fit-content;
max-width: 100%;
margin: 8px auto;
padding: 0;
list-style: none;
overflow-x: auto;
border-radius: 5px;
li {
white-space: nowrap;
a, span {
display: block;
padding: 8px 12px;
&:not(span):hover {
background: #0002;
@media (prefers-color-scheme: dark) {
background: #fff2;
}
}
}
}
}
}
</style>
{#if paginationObj && (paginationObj.hasPrevious() || paginationObj.hasNext())}
<nav class="pagination">
<ul class="panel">
{#if paginationObj.hasPrevious()}
<li><a href={route(routeName, {page: paginationObj.page - 1})}>
<Icon name="chevron-left"/>
Previous
</a></li>
{#each paginationObj.previousPages(contextSize) as i}
{#if i === -1}
<li class="ellipsis"><span>...</span></li>
{:else}
<li><a href={route(routeName, {page: i})}>{i}</a></li>
{/if}
{/each}
{/if}
<li class="active"><span>{paginationObj.page}</span></li>
{#if paginationObj.hasNext()}
{#each paginationObj.nextPages(contextSize) as i}
{#if i === -1}
<li class="ellipsis"><span>...</span></li>
{:else}
<li><a href={route(routeName, {page: i})}>{i}</a></li>
{/if}
{/each}
<li><a href={route(routeName, {page: paginationObj.page + 1})}>
Next
<Icon name="chevron-right"/>
</a></li>
{/if}
</ul>
</nav>
{/if}

View File

@ -1,5 +0,0 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={400} message="Bad request." />

View File

@ -1,5 +0,0 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={401} message="Unauthorized." />

View File

@ -1,5 +0,0 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={403} message="Forbidden" />

View File

@ -1,5 +0,0 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={404} message="Page not found." />

View File

@ -1,5 +0,0 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={429} message="Too many requests." />

View File

@ -1,5 +0,0 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={500} message="Internal server error." />

View File

@ -1,5 +0,0 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={503} message="Service unavailable." />

View File

@ -1,33 +0,0 @@
<script lang="ts">
import {locals} from "../ts/stores";
import {route, hasRoute, hasAnyRoute} from "../../common/Routing";
import BaseTemplate from "./templates/BaseTemplate.svelte";
import {onMount} from "svelte";
let randomTitleSWord = 'Svelte';
const possibleSWords = ['Svelte', 'Simple', 'Scalable', 'Super', 'Structure', 'Satisfying'];
onMount(() => {
randomTitleSWord = possibleSWords[Math.floor(Math.random() * possibleSWords.length)];
});
</script>
<BaseTemplate title="{$locals.app.name}" h1={false}>
<div class="panel">
<h1>swaf - {randomTitleSWord} Web Application Framework</h1>
<p>Welcome to {$locals.app.name}!</p>
{#if hasAnyRoute('tests', 'design')}
<nav>
<ul>
{#if hasRoute('tests')}
<li><a href={route('tests')}>Frontend tests</a></li>
{/if}
{#if hasRoute('design')}
<li><a href={route('design')}>Design test</a></li>
{/if}
</ul>
</nav>
{/if}
</div>
</BaseTemplate>

View File

@ -1,19 +0,0 @@
<script>
import {locals} from "../ts/stores";
import BaseTemplate from "./templates/BaseTemplate.svelte";
import Message from "./components/Message.svelte";
const actionType = $locals.magicLink?.action_type;
const h1 = 'Magic Link' + (actionType ? (' - ' + actionType) : '');
</script>
<BaseTemplate title="{$locals.app.name} {h1}" {h1}>
<div class="panel">
{#if $locals.err}
<Message type="error" content={$locals.err}/>
{:else}
<Message type="success" content="Success!"/>
<p>You can now close this page.</p>
{/if}
</div>
</BaseTemplate>

View File

@ -1,49 +0,0 @@
<script lang="ts">
import {locals} from "../ts/stores.js";
import BaseTemplate from "./templates/BaseTemplate.svelte";
import Message from "./components/Message.svelte";
import WebsocketClient from "../ts/WebsocketClient.js";
import {Time} from "../../common/Time.js";
import {onMount} from "svelte";
const validUntil = parseFloat($locals.validUntil as string);
function isValid() {
return new Date().getTime() < validUntil;
}
let countdown;
let validUntilDate = new Date(validUntil);
$: countdown = $locals.isSsr ? '...' : Time.humanizeTimeTo(validUntilDate);
onMount(() => {
const interval = setInterval(() => {
validUntilDate = new Date(validUntil);
}, 1000);
if (isValid()) {
const webSocket = new WebsocketClient($locals.websocketUrl as string, (websocket, e) => {
if (e.data === 'refresh') {
window.location.reload();
}
});
webSocket.run();
}
return () => {
clearInterval(interval);
};
});
</script>
<BaseTemplate h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
<div class="panel">
<Message type="success" sticky
content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/>
<Message type="info" discreet sticky raw
content={`This link will be valid for ${countdown} and can only be used once.`}/>
<p class="center">Waiting for you to open the link...</p>
</div>
</BaseTemplate>

View File

@ -1,27 +0,0 @@
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
Add this email address on {{ app.name }}
</mj-text>
<mj-text>
Someone wants to add <strong>{{ mail_to }}</strong> to their account.
<br><br>
<strong>Do not click on this if this is not you!</strong>
</mj-text>
<mj-button href="{{ link | safe }}">
Add <strong>{{ mail_to }}</strong> on {{ app.name }}
</mj-button>
</mj-column>
</mj-section>
{% endblock %}
{% block text %}
Hi!
Someone wants to add {{ mail_to }} to their account.
To add this email address, please follow this link: {{ link|safe }}
{% endblock %}

View File

@ -1,27 +0,0 @@
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
Remove your password on your {{ app.name }} account
</mj-text>
<mj-text>
Someone wants to remove your password from your account.
<br><br>
<strong>Do not click on this if this is not you!</strong>
</mj-text>
<mj-button href="{{ link | safe }}">
Remove my {{ app.name }} password
</mj-button>
</mj-column>
</mj-section>
{% endblock %}
{% block text %}
Hi!
Someone wants to remove your password from your {{ app.name }} account.
To confirm this action and remove your password, please follow this link: {{ link|safe }}
{% endblock %}

View File

@ -1,40 +0,0 @@
<script lang="ts">
import {onMount} from "svelte";
import Icon from "../utils/Icon.svelte";
let iconTemplate: HTMLTemplateElement;
function addExternalLinkIcons(): void {
console.log('Add icons to external links...');
const iconElement = iconTemplate.childNodes.item(0);
document.querySelectorAll('a[target="_blank"]').forEach(el => {
if (!el.classList.contains('no-icon')) {
el.classList.add('no-icon');
el.appendChild(iconElement.cloneNode(true));
}
});
}
onMount(() => {
addExternalLinkIcons();
new MutationObserver(() => {
addExternalLinkIcons();
}).observe(document.body, {
childList: true,
subtree: true,
});
});
</script>
<style>
div {
display: none;
}
</style>
<div bind:this={iconTemplate}>
<Icon name="external-link"/>
</div>

View File

@ -1,93 +0,0 @@
<script lang="ts">
import FlashMessages from "../components/FlashMessages.svelte";
import BaseFooter from "./base/BaseFooter.svelte";
import BaseHeader from "./base/BaseHeader.svelte";
import CommonScripts from "./CommonScripts.svelte";
import {locals} from '../../ts/stores.js';
export let title: string;
export let h1: string = title;
export let description: string;
export let previewImageUrl: string | undefined = undefined;
export let refresh_after: number | undefined = undefined;
export let noHeader: boolean = false;
export let noH1: boolean = false;
export let noFooter: boolean = false;
export let noLogoLabel = false;
export let noLoginLink = false;
</script>
<CommonScripts/>
<style lang="scss">
@import "../../scss/vars";
@import "../../scss/helpers";
main {
@include container;
flex-grow: 1;
}
.flash-messages {
@include container;
}
</style>
<svelte:head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{title || 'Undefined title'}</title>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content={title}>
<meta property="og:url" content="{$locals.app.public_url + $locals.url}">
<meta property="twitter:title" content={title}>
<meta property="twitter:url" content={$locals.app.public_url + $locals.url}>
{#if description}
<meta name="description" content={description}>
<meta property="og:description" content={description}>
<meta property="twitter:description" content={description}>
{/if}
{#if previewImageUrl}
<meta property="og:image" content={previewImageUrl}>
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:image" content={previewImageUrl}>
{/if}
<link rel="shortcut icon" type="image/png" href="/img/logox1024.png">
<link rel="shortcut icon" type="image/png" href="/img/logox128.png">
<link rel="shortcut icon" type="image/svg" href="/img/logo.svg">
{#if refresh_after}
<meta http-equiv="refresh" content={refresh_after}>
{/if}
<link rel="stylesheet" href="/css/layout.css">
</svelte:head>
{#if !noHeader}
<BaseHeader {noLogoLabel} {noLoginLink}/>
{/if}
<div class="flash-messages">
<FlashMessages/>
</div>
<main>
{#if h1 && !noH1}
<h1>{h1}</h1>
{/if}
{#if $$slots.subtitle}
<p>
<slot name="subtitle"/>
</p>
{/if}
<slot/>
</main>
{#if !noFooter}
<BaseFooter/>
{/if}

View File

@ -1,5 +0,0 @@
<script>
import ExternalLinkIcons from "../scripts/ExternalLinkIcons.svelte";
</script>
<ExternalLinkIcons/>

View File

@ -1,145 +0,0 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import FlashMessages from "../components/FlashMessages.svelte";
import Icon from "../utils/Icon.svelte";
import CommonScripts from "./CommonScripts.svelte";
const previousUrl = $locals.previousUrl;
export let code;
code = $locals.error_code || code;
export let message;
message = $locals.error_message || message;
export let instructions;
instructions = $locals.error_instructions || instructions;
</script>
<CommonScripts/>
<svelte:head>
<title>{code + ' - ' + message}</title>
<link rel="stylesheet" href="/css/layout.css">
</svelte:head>
<div class="logo"><a href="/">{$locals.app.name}</a></div>
<main>
<FlashMessages/>
<div class="error-code">{code}</div>
<div class="error-message">{message}</div>
{#if instructions}
<div class="error-instructions">{@html instructions}</div>
{/if}
<nav>
{#if previousUrl && previousUrl !== '/' && previousUrl !== $locals.url}
<a href={previousUrl} class="button bold"><Icon name="arrow-left"/> Go back</a>
{/if}
<a href="/" class="button"><Icon name="home"/> Go to homepage</a>
</nav>
</main>
<div class="contact">
<p>Error ID: {$locals.error_id || 'Request has no indentifier.'}</p>
<p>
If you think this isn't right, please contact us with the above error ID at
<a href="mailto:{$locals.app.contact_email}">{$locals.app.contact_email}</a>.
</p>
</div>
<style lang="scss">
header, footer {
margin: 0;
padding: 0;
height: 0;
}
main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.messages {
margin-bottom: 32px;
}
.error-code {
font-size: 36px;
}
.error-message {
font-size: 32px;
}
.error-instructions {
margin-top: 32px;
font-size: 20px;
}
nav {
margin-top: 32px;
}
&::before {
content: "Oops";
position: absolute;
z-index: -1;
font-size: #{'min(50vh, 40vw)'};
opacity: 0.025;
}
}
.contact {
text-align: center;
padding: 8px;
}
.logo {
position: absolute;
top: 0;
left: 0;
width: 100%;
margin-top: 24px;
text-align: center;
a {
position: relative;
padding: 16px;
color: var(--on-background);
&:hover {
color: #fff;
&::before {
opacity: 0.2;
}
}
&::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-image: url(../../img/logo.svg);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
opacity: 0.075;
filter: contrast(0);
}
}
}
</style>

View File

@ -1,12 +0,0 @@
<script>
import {locals} from "../../../ts/stores.js";
</script>
<style>
footer {
padding: 8px;
text-align: center;
}
</style>
<footer>{$locals.app.name} v{$locals.app.version} - all rights reserved.</footer>

View File

@ -1,46 +0,0 @@
<script>
import BaseHeaderLogo from "./BaseHeaderLogo.svelte";
import NavMenu from "../../components/NavMenu.svelte";
import BaseNavMenuLinks from "./BaseNavMenuLinks.svelte";
import BaseNavMenuAuth from "./BaseNavMenuAuth.svelte";
export let noLoginLink = false;
export let noLogoLabel = false;
</script>
<style lang="scss">
@import "../../../scss/vars";
@import "../../../scss/helpers";
header {
@if $headerContainer {
@include container;
}
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0;
height: $headerHeight;
@include medium-le {
z-index: 1;
position: sticky;
top: 0;
flex-direction: row-reverse;
@include surface(3);
}
}
</style>
<header>
<BaseHeaderLogo noLabel={noLogoLabel}/>
<NavMenu>
<BaseNavMenuLinks/>
<BaseNavMenuAuth {noLoginLink}/>
</NavMenu>
</header>

View File

@ -1,33 +0,0 @@
<script>
import {locals} from "../../../ts/stores.js";
export let noLabel = false;
</script>
<style lang="scss">
@import "../../../scss/vars";
.logo {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 8px;
font-size: 24px;
img {
flex-shrink: 0;
width: initial;
height: calc(#{$headerHeight} - 16px);
margin-right: 8px;
padding: 8px;
}
}
</style>
<a href="/" class="logo">
<img src="/img/logo.svg" alt="{$locals.app.name} logo">
{#if !noLabel}
<span class="label">{$locals.app.name}</span>
{/if}
</a>

View File

@ -1,27 +0,0 @@
<script>
import {locals} from "../../../ts/stores.js";
import NavMenuItem from "../../components/NavMenuItem.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import NavMenuDropdown from "../../components/NavMenuDropdown.svelte";
import BaseNavMenuAuthAccountDropdownAdditionalLinks from "./BaseNavMenuAuthAccountDropdownAdditionalLinks.svelte";
export let noLoginLink = false;
let accountItemHovered;
</script>
{#if hasRoute('auth')}
{#if $locals.user}
{#if $locals.user.is_admin}
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
{/if}
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'} bind:hovered={accountItemHovered}>
<NavMenuDropdown bind:open={accountItemHovered}>
<BaseNavMenuAuthAccountDropdownAdditionalLinks/>
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
</NavMenuDropdown>
</NavMenuItem>
{:else if !noLoginLink}
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
{/if}
{/if}

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
%head%
<style>%css%</style>
<script type="module" defer>
import View from '/js/views/%canonicalViewName%.js';
import * as Routing from '/js/Routing.js';
const setRoutes = Routing.R.setRoutes;
const setPublicUrl = Routing.R.setPublicUrl;
import * as stores from '/js/stores.js';
const localStore = stores.l;
localStore.set(%locals%);
setRoutes(%routes%);
setPublicUrl(`%publicUrl%`);
new View({
hydrate: true,
target: document.body,
});
</script>
</head>
<body>
%html%
</body>
</html>

View File

@ -1,15 +0,0 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"outDir": "public/js",
"rootDir": "../../../intermediates/assets",
},
"include": [
"src/assets/ts/**/*"
],
"references": [
{
"path": "../../common"
}
]
}

View File

@ -1,6 +0,0 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import Field from "./Field.svelte";
</script>
<Field type="hidden" name="csrf" value={$locals.csrfToken}/>

View File

@ -1,484 +0,0 @@
<script lang="ts">
import {locals} from '../../ts/stores.js';
import {FileSize} from "../../../common/FileSize.js";
import Message from "../components/Message.svelte";
import Icon from "./Icon.svelte";
import {getContext} from "svelte";
import {dateToDatetimeLocal, dateToIsoString} from "../../ts/datetime-local.js";
export let type: string;
export let name: string;
type FieldValue = string | number | boolean | Record<string, FieldValue>;
export let value: FieldValue | undefined = undefined;
export let initialValue: FieldValue | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let hint: string | undefined = undefined;
export let extraData: string[] | undefined = undefined;
export let icon: string | undefined = undefined;
export let validation = $locals.flash.validation?.[0]?.[name] as { message: string, value?: string } | undefined;
const formId = getContext('formId');
const fieldId = `${formId}-${name}-field`;
const previousFormData = $locals.flash.previousFormData?.[0] as Record<string, FieldValue> | undefined || {};
let previousFieldData = previousFormData[name];
if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData);
value = type !== 'hidden' && previousFieldData || value || initialValue || validation?.value || '';
$: initialDatetimeLocalValue = type === 'datetime-local' && typeof value === 'string' ? dateToDatetimeLocal(new Date(value)) : undefined;
function durationValue(f: string): number {
if (previousFormData[name]) {
return value[f];
}
switch (f) {
case 's':
return value % 60;
case 'm':
return (value - value % 60) / 60 % 60;
case 'h':
return (value - value % 3600) / 3600;
default:
return 0;
}
}
function focusInput(e) {
if (input) {
if (input.type === 'checkbox') {
if (e.target !== label && e.target !== input) {
input.click();
}
} else if (['file', 'color'].indexOf(input.type) >= 0) {
input.click();
} else {
input.focus();
}
} else {
this.querySelector('input')?.focus();
}
}
function handleInput() {
switch (this.type) {
case 'number':
case 'range':
value = +this.value;
break;
case 'file':
handleFileInput();
break;
case 'datetime-local':
value = dateToIsoString(new Date(this.value));
break;
case 'checkbox':
value = !!this.checked;
break;
default:
value = this.value;
break;
}
}
let input: HTMLInputElement;
let label: HTMLLabelElement;
function chooseFile() {
input.click();
}
export let fileList: FileList | undefined = undefined;
function handleFileInput() {
fileList = input.files;
}
let focused = false;
</script>
<style lang="scss">
@import "../../scss/helpers";
.form-field:not(.hidden) {
display: flex;
flex-direction: column;
margin: 16px auto;
.control {
position: relative;
z-index: 0;
display: flex;
align-items: start;
flex-direction: row;
color: var(--on-input);
background-color: var(--input);
border-radius: 5px;
> :global(.icon) {
--icon-size: 24px;
margin: 18px;
opacity: 0.75;
}
}
label {
position: absolute;
z-index: 1;
left: 8px;
top: 22px;
user-select: none;
font-size: 16px;
transition-property: top, font-size;
transition-duration: 150ms;
transition-timing-function: ease-out;
cursor: text;
}
.has-icon label {
left: 68px;
}
&.disabled {
opacity: 0.5;
&, * {
cursor: not-allowed;
}
}
input, select, textarea {
z-index: 1;
border: 0;
color: inherit;
background: transparent;
font-size: 16px;
border-radius: 5px;
outline-offset: 0;
}
&:not(.empty),
select ~,
[type="file"] ~,
[type="color"] ~,
[type="number"] ~,
.focused ~,
:focus ~,
fieldset {
.sections label, legend, .time-input label {
top: 8px;
font-size: 14px;
}
}
input,
select,
select,
textarea,
.form-display,
.textarea-growing-wrapper,
.textarea-growing-wrapper:after {
display: block;
padding: 32px 8px 8px;
width: 100%;
height: 60px;
}
select {
position: relative;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
&::-ms-expand {
display: none;
}
& + :global(.icon) {
position: absolute;
pointer-events: none;
right: 0;
top: 0;
transition: transform 150ms ease-out;
}
// TODO: Temporary
&:focus + :global(.icon) {
transform: rotateX(180deg);
}
}
.textarea-growing-wrapper {
display: grid;
flex-grow: 1;
height: revert;
padding: 0;
&:after {
content: attr(data-value) " ";
color: red;
box-sizing: border-box;
font: inherit;
visibility: hidden;
}
textarea {
height: auto;
resize: none;
font-family: inherit;
overflow: hidden;
}
&:after, textarea {
grid-area: 1 / 1 / 2 / 2;
margin-left: revert;
min-height: 100px;
height: revert;
white-space: pre-wrap;
word-wrap: anywhere;
}
}
.has-icon > {
input,
select,
select,
fieldset,
.form-display,
.textarea-growing-wrapper,
.textarea-growing-wrapper:after,
.textarea-growing-wrapper > textarea {
margin-left: -60px;
padding-left: 68px;
}
.textarea-growing-wrapper > textarea {
margin-left: -68px;
width: calc(100% + 68px);
}
}
input[type=color] {
height: calc(32px + 8px + 32px);
}
&.checkbox, &.color, &.file {
input, label {
cursor: pointer;
}
}
&.checkbox {
.control {
display: flex;
flex-direction: row;
align-items: stretch;
flex-grow: 1;
}
input {
width: 20px;
height: 20px;
margin: auto 8px;
text-align: left;
& ~ .sections {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
label {
position: static;
font-size: 16px;
}
}
}
}
&.file {
input {
@include fake-hide;
}
label {
position: static;
display: block;
padding: 8px;
font-size: revert !important;
}
}
.files {
display: flex;
flex-direction: column;
align-items: start;
.file {
margin: 8px;
padding: 8px;
color: var(--on-primary);
background: var(--primary);
border-radius: 5px;
> div {
display: inline-block;
padding: 8px;
}
.name {
font-size: 20px;
:global(.icon) {
--icon-size: 24px;
margin-right: 8px;
}
}
}
}
fieldset {
display: flex;
flex-direction: row;
align-items: start;
width: 100%;
margin: 0;
padding-top: 8px;
border: 0;
legend {
padding: 8px;
}
.time-input {
position: relative;
flex: 1;
margin: 0;
label {
left: 8px;
}
}
+ {
.error, .hint {
margin-top: -16px;
margin-bottom: 16px;
}
}
}
}
.form-field, fieldset + {
.error, .hint {
padding: 2px 2px 2px 4px;
text-align: left;
font-size: 14px;
color: var(--color);
}
}
</style>
{#if type === 'hidden'}
{#if validation}
<Message type="error" content={validation.message}/>
{/if}
<input type="hidden" name={name} value={value}>
{:else}
<div class="form-field"
class:checkbox={type === 'checkbox'}
class:color={type === 'color'}
class:file={type === 'file'}
class:empty={value === ''}
class:disabled={Object.keys($$restProps).indexOf('disabled') >= 0}>
<div class="control" class:has-icon={icon} on:click={focusInput}>
{#if icon}
<Icon name={icon}/>
{/if}
{#if type === 'duration'}
<fieldset>
<legend>{placeholder}</legend>
{#each Object.keys(extraData) as f}
<div class="time-input">
<input type="number" name="{name}[{f}]" id="{fieldId}-{f}"
value={durationValue(f)}
min="0" max={(f === 's' || f === 'm') && '60' || undefined}
{...$$restProps} on:click={e => e.stopPropagation()}>
<label for="{fieldId}-{f}" on:click={e => e.stopPropagation()}>{extraData[f] || f}</label>
</div>
{/each}
</fieldset>
{:else if type === 'select'}
<select name={name} id={fieldId} {...$$restProps} bind:this={input} bind:value={value} on:input={handleInput}>
{#each extraData as option}
<option value={(option.display === undefined || option.value !== undefined) && (option.value || option)}
>{option.display || option}</option>
{/each}
</select>
<Icon name="chevron-down"/>
{:else if type === 'textarea'}
<div class="textarea-growing-wrapper" class:focused={focused} data-value={value}>
<textarea {name} id={fieldId} {value} {...$$restProps} bind:this={input}
on:input={handleInput}
on:focusin={() => focused = true}
on:focusout={() => focused = false}></textarea>
</div>
{:else if type === 'checkbox'}
<input {type} {name} id={fieldId} checked={!!value} {...$$restProps} bind:this={input}
on:change={handleInput}>
{:else if type === 'datetime-local'}
<input {type} bind:this={input} on:input={handleInput} value={initialDatetimeLocalValue}>
<input type="hidden" {name} {value}>
{:else}
<input {type} {name} id={fieldId} {value} {...$$restProps} bind:this={input} on:input={handleInput}
tabindex={type === 'file' ? '-1' : undefined}>
{/if}
<div class="sections">
{#if type !== 'duration'}
<label for={fieldId} bind:this={label}>{@html placeholder || ''}
<slot/>
</label>
{/if}
{#if type === 'file'}
{#if fileList}
<div class="files">
{#each fileList as file}
<div class="file">
<div class="name" title="Type: {file.type}">
<Icon name="file"/> {file.name}
</div>
<div class="size" title="{file.size} bytes">
{FileSize.humanizeFileSize(file.size, true)}
</div>
</div>
{/each}
</div>
{/if}
<button type="button" on:click={chooseFile}>Browse...</button>
{/if}
</div>
</div>
{#if validation}
<div class="error">
<Icon name="alert-circle"/> {validation.message}</div>
{/if}
{#if hint}
<div class="hint">
<Icon name="info"/> {hint}</div>
{/if}
</div>
{/if}

View File

@ -1,61 +0,0 @@
<script context="module">
let nextAvailableFormId = 0;
</script>
<script lang="ts">
import CsrfTokenField from "./CsrfTokenField.svelte";
import Icon from "./Icon.svelte";
import {setContext} from "svelte";
export let action: string;
export let button: boolean = false;
export let submitText: string;
export let submitIcon: string;
export let submitClass: string = undefined;
export let submitDisabled: boolean = false;
export let isBoldSubmit: boolean = true;
export let resetButton: boolean = false;
export let confirm: string = undefined;
export let withFiles: boolean = false;
const formId = nextAvailableFormId++;
setContext('formId', formId);
export let onSubmit = function(e) {
if (submitDisabled || confirm && !window.confirm(confirm)) {
e.preventDefault();
}
};
</script>
<style lang="scss">
.form-controls {
display: flex;
justify-content: space-between;
> :last-child:not(* + :last-child) {
margin-left: auto;
margin-right: auto;
}
}
</style>
<form {action} method="POST" id="{formId}-form" on:submit={onSubmit} enctype={withFiles ? 'multipart/form-data' : undefined}>
<CsrfTokenField/>
<slot/>
<div class="form-controls">
{#if resetButton}
<button type="reset"><Icon name="trash"/>Reset</button>
{/if}
<button type="submit" class={submitClass} class:bold={isBoldSubmit} disabled={submitDisabled}>
{#if submitIcon}
<Icon name={submitIcon}/>
{/if}
{#if button}
<span class="tip">{submitText}</span>
{:else}
{submitText}
{/if}
</button>
</div>
</form>

View File

@ -1,47 +0,0 @@
<script lang="ts">
import {replaceIcons, isLucideIcon} from "../../ts/icons.js";
import {afterUpdate, onMount} from "svelte";
export let name: string;
onMount(() => {
replaceIcons(true);
});
afterUpdate(() => {
replaceIcons(false);
});
</script>
<style lang="scss">
:global(.icon) {
display: inline-flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: var(--icon-size);
height: var(--icon-size);
--icon-size: 16px;
font-size: var(--icon-size);
stroke: currentColor;
stroke-width: 2;
stroke-linecap: square;
stroke-linejoin: miter;
fill: none;
vertical-align: middle;
h1 > &, h2 > &, h3 > & {
--icon-size: 24px;
}
}
</style>
{#if name}
{#if isLucideIcon(name) >= 0 }
<i icon-name="{name}" class="icon" aria-hidden="true" {...$$restProps}></i>
{:else}
<i class="{name} icon" aria-hidden="true" {...$$restProps}></i>
{/if}
{/if}

View File

@ -1,215 +0,0 @@
import config from "config";
import {Request, Response} from "express";
import {route} from "../common/Routing.js";
import {Time} from "../common/Time.js";
import Controller from "../Controller.js";
import ModelFactory from "../db/ModelFactory.js";
import Validator, {EMAIL_REGEX, InvalidFormatValidationError} from "../db/Validator.js";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "../HttpError.js";
import MailTemplate from "../mail/MailTemplate.js";
import {ADD_EMAIL_MAIL_TEMPLATE, REMOVE_PASSWORD_MAIL_TEMPLATE} from "../Mails.js";
import {RequireAuthMiddleware} from "./AuthComponent.js";
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType.js";
import MagicLinkController from "./magic_link/MagicLinkController.js";
import User from "./models/User.js";
import UserEmail from "./models/UserEmail.js";
import UserNameComponent from "./models/UserNameComponent.js";
import UserPasswordComponent from "./password/UserPasswordComponent.js";
export default class AccountController extends Controller {
public constructor(
private readonly addEmailMailTemplate: MailTemplate = ADD_EMAIL_MAIL_TEMPLATE,
private readonly removePasswordMailTemplate: MailTemplate = REMOVE_PASSWORD_MAIL_TEMPLATE,
) {
super();
}
public getRoutesPrefix(): string {
return '/account';
}
public routes(): void {
this.get('/', this.getAccount, 'account', RequireAuthMiddleware);
if (ModelFactory.get(User).hasComponent(UserNameComponent)) {
this.post('/change-name', this.postChangeName, 'change-name', RequireAuthMiddleware);
}
if (ModelFactory.get(User).hasComponent(UserPasswordComponent)) {
this.post('/change-password', this.postChangePassword, 'change-password', RequireAuthMiddleware);
this.post('/remove-password', this.postRemovePassword, 'remove-password', RequireAuthMiddleware);
}
this.post('/add-email', this.addEmail, 'add-email', RequireAuthMiddleware);
this.post('/set-main-email', this.postSetMainEmail, 'set-main-email', RequireAuthMiddleware);
this.post('/remove-email', this.postRemoveEmail, 'remove-email', RequireAuthMiddleware);
}
protected async getAccount(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const passwordComponent = user.asOptional(UserPasswordComponent);
const nameComponent = user.asOptional(UserNameComponent);
const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period');
const nameChangedAt = nameComponent?.getNameChangedAt()?.getTime() || Date.now();
const nameChangeRemainingTime = new Date(nameChangedAt + nameChangeWaitPeriod);
res.formatViewData('auth/account/account', {
user_personal_info_fields: user.getPersonalInfoFields(),
main_email: await user.mainEmail.get(),
emails: await user.emails.get(),
display_email_warning: config.get('app.display_email_warning'),
has_password_component: !!passwordComponent,
has_password: passwordComponent?.hasPassword(),
has_name_component: !!nameComponent,
name_change_wait_period: Time.humanizeDuration(nameChangeWaitPeriod, false, true),
can_change_name: nameComponent?.canChangeName(),
can_change_name_in: Time.humanizeTimeTo(nameChangeRemainingTime),
});
}
protected async postChangeName(req: Request, res: Response): Promise<void> {
await Validator.validate({
'name': new Validator().defined(),
}, req.body);
const user = req.as(RequireAuthMiddleware).getUser();
const userNameComponent = user.as(UserNameComponent);
if (!userNameComponent.setName(req.body.name)) {
const nameChangedAt = userNameComponent.getNameChangedAt()?.getTime() || Date.now();
const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period');
req.flash('error', `Your can't change your name until ${new Date(nameChangedAt + nameChangeWaitPeriod)}.`);
res.redirect(route('account'));
return;
}
await user.save();
req.flash('success', `Your name was successfully changed to ${req.body.name}.`);
res.redirect(route('account'));
}
protected async postChangePassword(req: Request, res: Response): Promise<void> {
const validationMap = {
'new_password': new Validator().defined(),
'new_password_confirmation': new Validator().sameAs('new_password', req.body.new_password),
};
await Validator.validate(validationMap, req.body);
const user = req.as(RequireAuthMiddleware).getUser();
const passwordComponent = user.as(UserPasswordComponent);
if (passwordComponent.hasPassword() && !await passwordComponent.verifyPassword(req.body.current_password)) {
req.flash('error', 'Invalid current password.');
res.redirect(route('account'));
return;
}
await passwordComponent.setPassword(req.body.new_password, 'new_password');
await user.save();
req.flash('success', 'Password changed successfully.');
res.redirect(route('account'));
}
protected async postRemovePassword(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const mainEmail = await user.mainEmail.get();
if (!mainEmail || !mainEmail.email) {
req.flash('error', 'You can\'t remove your password without adding an email address first.');
res.redirect(route('account'));
return;
}
await MagicLinkController.sendMagicLink(
this.getApp(),
req.getSession().id,
AuthMagicLinkActionType.REMOVE_PASSWORD,
route('account'),
mainEmail.email,
this.removePasswordMailTemplate,
{},
);
res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: route('account'),
}));
}
protected async addEmail(req: Request, res: Response): Promise<void> {
await Validator.validate({
email: new Validator().defined().regexp(EMAIL_REGEX),
}, req.body);
const email = req.body.email;
// Existing email
if (await UserEmail.select().where('email', email).first()) {
const error = new InvalidFormatValidationError('You already have this email.');
error.thingName = 'email';
throw error;
}
await MagicLinkController.sendMagicLink(
this.getApp(),
req.getSession().id,
AuthMagicLinkActionType.ADD_EMAIL,
route('account'),
email,
this.addEmailMailTemplate,
{
email: email,
},
);
res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: route('account'),
}));
}
protected async postSetMainEmail(req: Request, res: Response): Promise<void> {
if (!req.body.id)
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
const user = req.as(RequireAuthMiddleware).getUser();
const userEmail = await UserEmail.getById(req.body.id);
if (!userEmail)
throw new NotFoundHttpError('email', req.url);
if (userEmail.user_id !== user.id)
throw new ForbiddenHttpError('email', req.url);
if (userEmail.id === user.main_email_id)
throw new BadRequestError('This address is already your main address',
'Try refreshing the account page.', req.url);
user.main_email_id = userEmail.id;
await user.save();
req.flash('success', 'This email was successfully set as your main address.');
res.redirect(route('account'));
}
protected async postRemoveEmail(req: Request, res: Response): Promise<void> {
if (!req.body.id)
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
const user = req.as(RequireAuthMiddleware).getUser();
const userEmail = await UserEmail.getById(req.body.id);
if (!userEmail)
throw new NotFoundHttpError('email', req.url);
if (userEmail.user_id !== user.id)
throw new ForbiddenHttpError('email', req.url);
if (userEmail.id === user.main_email_id)
throw new BadRequestError('Cannot remove main email address', 'Try refreshing the account page.', req.url);
await userEmail.delete();
req.flash('success', 'This email was successfully removed from your account.');
res.redirect(route('account'));
}
}

View File

@ -1,42 +1,39 @@
import ApplicationComponent from "../ApplicationComponent";
import {NextFunction, Request, Response} from "express";
import Application from "../Application.js";
import ApplicationComponent from "../ApplicationComponent.js";
import {route} from "../common/Routing.js";
import {ForbiddenHttpError} from "../HttpError.js";
import Middleware from "../Middleware.js";
import AuthGuard from "./AuthGuard.js";
import AuthMethod from "./AuthMethod.js";
import AuthProof from "./AuthProof.js";
import User from "./models/User.js";
import AuthGuard from "./AuthGuard";
import Controller from "../Controller";
import {ForbiddenHttpError} from "../HttpError";
import Middleware from "../Middleware";
import User from "./models/User";
import AuthProof from "./AuthProof";
export default class AuthComponent extends ApplicationComponent {
private readonly authGuard: AuthGuard;
private readonly authGuard: AuthGuard<AuthProof<User>>;
public constructor(app: Application, ...authMethods: AuthMethod<AuthProof<User>>[]) {
public constructor(authGuard: AuthGuard<AuthProof<User>>) {
super();
this.authGuard = new AuthGuard(app, ...authMethods);
this.authGuard = authGuard;
}
public async initRoutes(): Promise<void> {
public async init(): Promise<void> {
this.use(AuthMiddleware);
}
public getAuthGuard(): AuthGuard {
public getAuthGuard(): AuthGuard<AuthProof<User>> {
return this.authGuard;
}
}
export class AuthMiddleware extends Middleware {
private authGuard?: AuthGuard;
private authGuard?: AuthGuard<AuthProof<User>>;
private user: User | null = null;
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
this.authGuard = this.app.as(AuthComponent).getAuthGuard();
const proofs = await this.authGuard.getProofsForSession(req.getSession());
if (proofs.length > 0) {
this.user = await proofs[0].getResource();
const proof = await this.authGuard.isAuthenticated(req.getSession());
if (proof) {
this.user = await proof.getResource();
res.locals.user = this.user;
}
@ -47,7 +44,7 @@ export class AuthMiddleware extends Middleware {
return this.user;
}
public getAuthGuard(): AuthGuard {
public getAuthGuard(): AuthGuard<AuthProof<User>> {
if (!this.authGuard) throw new Error('AuthGuard was not initialized.');
return this.authGuard;
}
@ -57,8 +54,8 @@ export class RequireRequestAuthMiddleware extends Middleware {
private user?: User;
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForRequest(req);
const user = await proofs[0]?.getResource();
const proof = await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req);
const user = await proof?.getResource();
if (user) {
this.user = user;
next();
@ -66,7 +63,7 @@ export class RequireRequestAuthMiddleware extends Middleware {
}
req.flash('error', `You must be logged in to access ${req.url}.`);
res.redirect(route('auth', undefined, {
res.redirect(Controller.route('auth', undefined, {
redirect_uri: req.url,
}));
}
@ -84,8 +81,8 @@ export class RequireAuthMiddleware extends Middleware {
const authGuard = req.as(AuthMiddleware).getAuthGuard();
// Via request
let proofs = await authGuard.getProofsForRequest(req);
let user = await proofs[0]?.getResource();
let proof = await authGuard.isAuthenticatedViaRequest(req);
let user = await proof?.getResource();
if (user) {
this.user = user;
next();
@ -93,8 +90,8 @@ export class RequireAuthMiddleware extends Middleware {
}
// Via session
proofs = await authGuard.getProofsForSession(req.getSession());
user = await proofs[0]?.getResource();
proof = await authGuard.isAuthenticated(req.getSession());
user = await proof?.getResource();
if (user) {
this.user = user;
next();
@ -102,7 +99,7 @@ export class RequireAuthMiddleware extends Middleware {
}
req.flash('error', `You must be logged in to access ${req.url}.`);
res.redirect(route('auth', undefined, {
res.redirect(Controller.route('auth', undefined, {
redirect_uri: req.url,
}));
}
@ -115,9 +112,8 @@ export class RequireAuthMiddleware extends Middleware {
export class RequireGuestMiddleware extends Middleware {
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForSession(req.getSession());
if (proofs.length > 0) {
res.redirect(route('home'));
if (await req.as(AuthMiddleware).getAuthGuard().isAuthenticated(req.getSession())) {
res.redirectBack();
return;
}

View File

@ -1,141 +1,35 @@
import Controller from "../Controller";
import {NextFunction, Request, Response} from "express";
import {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent";
import Controller from "../Controller.js";
import ModelFactory from "../db/ModelFactory.js";
import {UnknownRelationValidationError} from "../db/Validator.js";
import {BadRequestError} from "../HttpError.js";
import AuthComponent, {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent.js";
import AuthMethod from "./AuthMethod.js";
import AuthProof from "./AuthProof.js";
import User from "./models/User.js";
import UserNameComponent from "./models/UserNameComponent.js";
import UserPasswordComponent from "./password/UserPasswordComponent.js";
export default class AuthController extends Controller {
public static flashSuccessfulAuthenticationWelcomeMessage(
user: User,
req: Request,
messagePrefix: string,
): void {
const name = user.asOptional(UserNameComponent)?.getName();
req.flash('success', `${messagePrefix} Welcome${name ? `, ${name}` : ''}.`);
}
export default abstract class AuthController extends Controller {
public getRoutesPrefix(): string {
return '/auth';
}
public routes(): void {
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
this.use(async (req, res, next) => {
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
if (await authGuard.interruptAuth(req, res)) return;
next();
});
this.get('/', this.getAuth, 'auth', RequireGuestMiddleware);
this.post('/login', this.postLogin, 'login', RequireGuestMiddleware);
this.post('/register', this.postRegister, 'register', RequireGuestMiddleware);
this.post('/', this.postAuth, 'auth', RequireGuestMiddleware);
this.get('/check', this.getCheckAuth, 'check_auth');
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
}
protected async getAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
const userModelFactory = ModelFactory.get(User);
const hasUsername = userModelFactory.hasComponent(UserNameComponent);
const hasPassword = userModelFactory.hasComponent(UserPasswordComponent);
res.formatViewData('auth/auth', {
auth_methods: authGuard.getAuthMethodNames(),
hasUsername: hasUsername,
hasPassword: hasPassword,
canRegisterWithPassword: hasUsername && hasPassword,
const registerEmail = req.flash('register_confirm_email');
res.render('auth/auth', {
register_confirm_email: registerEmail.length > 0 ? registerEmail[0] : null,
});
}
protected async postLogin(req: Request, res: Response): Promise<void> {
return await this.handleAuth(req, res, false);
}
protected abstract async postAuth(req: Request, res: Response, next: NextFunction): Promise<void>;
protected async postRegister(req: Request, res: Response): Promise<void> {
return await this.handleAuth(req, res, true);
}
protected async handleAuth(req: Request, res: Response, isRegistration: boolean): Promise<void> {
if (isRegistration && !req.body.auth_method) {
throw new BadRequestError('Cannot register without specifying desired auth_method.',
'Please specify auth_method.', req.url);
}
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
const identifier = req.body.identifier;
if (!identifier) throw new BadRequestError('Identifier not specified.', 'Please try again.', req.originalUrl);
// Get requested auth method
if (req.body.auth_method) {
const method = await authGuard.getAuthMethodByName(req.body.auth_method);
if (!method) {
throw new BadRequestError('Invalid auth method: ' + req.body.auth_method,
'Available methods are: ' + authGuard.getAuthMethodNames(), req.url);
}
// Register
if (isRegistration) return await method.attemptRegister(req, res, identifier);
const user = await method.findUserByIdentifier(identifier);
// Redirect to registration if user not found
if (!user) return await this.redirectToRegistration(req, res, identifier);
// Login
return await method.attemptLogin(req, res, user);
}
const methods = await authGuard.getAuthMethodsByIdentifier(identifier);
// Redirect to registration if user not found
if (methods.length === 0) return await this.redirectToRegistration(req, res, identifier);
// Choose best matching method
let user: User | null = null;
let method: AuthMethod<AuthProof<User>> | null = null;
let weight = -1;
for (const entry of methods) {
const methodWeight = entry.method.getWeightForRequest(req);
if (methodWeight > weight) {
user = entry.user;
method = entry.method;
weight = methodWeight;
}
}
if (!method || !user) ({method, user} = methods[0]); // Default to first method
// Login
return await method.attemptLogin(req, res, user);
}
protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void>;
protected async postLogout(req: Request, res: Response, _next: NextFunction): Promise<void> {
const userId = typeof req.body.user_id === 'string' ? parseInt(req.body.user_id) : null;
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofs(req);
for (const proof of proofs) {
if (userId === null || (await proof.getResource())?.id === userId) {
await proof.revoke();
}
}
const proof = await req.as(AuthMiddleware).getAuthGuard().getProof(req);
await proof?.revoke();
req.flash('success', 'Successfully logged out.');
res.redirect(req.getIntendedUrl() || '/');
res.redirect(req.query.redirect_uri?.toString() || '/');
}
protected async redirectToRegistration(req: Request, res: Response, identifier: string): Promise<void> {
const error = new UnknownRelationValidationError(User.table, 'identifier');
error.thingName = 'identifier';
error.value = identifier;
throw error;
}
}

View File

@ -1,116 +1,63 @@
import config from "config";
import {Request, Response} from "express";
import {Session, SessionData} from "express-session";
import AuthProof from "./AuthProof";
import MysqlConnectionManager from "../db/MysqlConnectionManager";
import User from "./models/User";
import {Connection} from "mysql";
import {Request} from "express";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
import Mail from "../Mail";
import Controller from "../Controller";
import config from "config";
import Application from "../Application";
import NunjucksComponent from "../components/NunjucksComponent";
import Application from "../Application.js";
import {route} from "../common/Routing.js";
import MailComponent from "../components/MailComponent.js";
import MysqlConnectionManager from "../db/MysqlConnectionManager.js";
import Mail from "../mail/Mail.js";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails.js";
import AuthMethod from "./AuthMethod.js";
import AuthProof from "./AuthProof.js";
import User from "./models/User.js";
import UserNameComponent from "./models/UserNameComponent.js";
export default class AuthGuard {
private readonly authMethods: AuthMethod<AuthProof<User>>[];
export default abstract class AuthGuard<P extends AuthProof<User>> {
public constructor(
private readonly app: Application,
...authMethods: AuthMethod<AuthProof<User>>[]
) {
this.authMethods = authMethods;
}
public async interruptAuth(req: Request, res: Response): Promise<boolean> {
for (const method of this.authMethods) {
if (method.interruptAuth && await method.interruptAuth(req, res)) return true;
protected abstract async getProofForSession(session: Express.Session): Promise<P | null>;
protected async getProofForRequest(_req: Request): Promise<P | null> {
return null;
}
public async getProof(req: Request): Promise<P | null> {
let proof = await this.isAuthenticatedViaRequest(req);
if (!proof && req.session) {
proof = await this.isAuthenticated(req.session);
}
return proof;
}
public async isAuthenticated(session: Express.Session): Promise<P | null> {
if (!session.is_authenticated) return null;
const proof = await this.getProofForSession(session);
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) {
await proof?.revoke();
session.is_authenticated = false;
return null;
}
return false;
return proof;
}
public getAuthMethodByName(authMethodName: string): AuthMethod<AuthProof<User>> | null {
return this.authMethods.find(m => m.getName() === authMethodName) || null;
}
public async isAuthenticatedViaRequest(req: Request): Promise<P | null> {
const proof = await this.getProofForRequest(req);
public getAuthMethodNames(): string[] {
return this.authMethods.map(m => m.getName());
}
public getRegistrationMethod(): AuthMethod<AuthProof<User>> {
return this.authMethods[0];
}
public async getAuthMethodsByIdentifier(
identifier: string,
): Promise<{ user: User, method: AuthMethod<AuthProof<User>> }[]> {
const methods = [];
for (const method of this.authMethods) {
const user = await method.findUserByIdentifier(identifier);
if (user) methods.push({user, method});
}
return methods;
}
public async getProofs(req: Request): Promise<AuthProof<User>[]> {
const proofs = [];
if (req.getSessionOptional()) {
proofs.push(...await this.getProofsForSession(req.session));
}
proofs.push(...await this.getProofsForRequest(req));
return proofs;
}
public async getProofsForSession(session: Session & Partial<SessionData>): Promise<AuthProof<User>[]> {
if (!session.isAuthenticated) return [];
const proofs = [];
for (const method of this.authMethods) {
if (method.getProofsForSession) {
const methodProofs = await method.getProofsForSession(session);
for (const proof of methodProofs) {
if (!await proof.isValid() || !await proof.isAuthorized()) {
await proof.revoke();
} else {
proofs.push(proof);
}
}
}
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) {
await proof?.revoke();
return null;
}
if (proofs.length === 0) {
session.isAuthenticated = false;
session.persistent = false;
}
return proofs;
}
public async getProofsForRequest(req: Request): Promise<AuthProof<User>[]> {
const proofs = [];
for (const method of this.authMethods) {
if (method.getProofsForRequest) {
const methodProofs = await method.getProofsForRequest(req);
for (const proof of methodProofs) {
if (!await proof.isValid() || !await proof.isAuthorized()) {
await proof.revoke();
} else {
proofs.push(proof);
}
}
}
}
return proofs;
return proof;
}
public async authenticateOrRegister(
session: Session & Partial<SessionData>,
proof: AuthProof<User>,
persistSession: boolean,
session: Express.Session,
proof: P,
onLogin?: (user: User) => Promise<void>,
beforeRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
afterRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
@ -120,11 +67,6 @@ export default class AuthGuard {
let user = await proof.getResource();
// Revoke proof early if user is not approved
if (user && !user.isApproved() || !user && User.isApprovalMode()) {
await proof.revoke();
}
// Register if user doesn't exist
if (!user) {
const callbacks: RegisterCallback[] = [];
@ -145,13 +87,11 @@ export default class AuthGuard {
await callback();
}
if (User.isApprovalMode()) {
await this.app.as(MailComponent).sendMail(new Mail(PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: user.asOptional(UserNameComponent)?.getName() ||
(await user.mainEmail.get())?.getOrFail('email') ||
'Could not find an identifier',
link: route('accounts-approval', {}, {}, true),
}), config.get<string>('app.contact_email'));
if (!user.isApproved()) {
await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: (await user.mainEmail.get())?.getOrFail('email'),
link: config.get<string>('base_url') + Controller.route('accounts-approval'),
}).send(config.get<string>('app.contact_email'));
}
}
@ -160,16 +100,13 @@ export default class AuthGuard {
throw new PendingApprovalAuthError();
}
// Mark auth proof as used
await proof.use?.();
// Login
session.isAuthenticated = true;
session.persistent = persistSession;
session.is_authenticated = true;
if (onLogin) await onLogin(user);
return user;
}
}
export class AuthError extends Error {

View File

@ -1,36 +0,0 @@
import {Request, Response} from "express";
import {Session} from "express-session";
import AuthProof from "./AuthProof.js";
import User from "./models/User.js";
export default interface AuthMethod<P extends AuthProof<User>> {
/**
* @return A unique name.
*/
getName(): string;
/**
* Used for automatic auth method detection. Won't affect forced auth method.
*
* @return {@code 0} if the request is not conform to this auth method, otherwise the exact count of matching
* fields.
*/
getWeightForRequest(req: Request): number;
findUserByIdentifier(identifier: string): Promise<User | null>;
getProofsForSession?(session: Session): Promise<P[]>;
getProofsForRequest?(req: Request): Promise<P[]>;
/**
* @return {@code true} if interrupted, {@code false} otherwise.
*/
interruptAuth?(req: Request, res: Response): Promise<boolean>;
attemptLogin(req: Request, res: Response, user: User): Promise<void>;
attemptRegister(req: Request, res: Response, identifier: string): Promise<void>;
}

View File

@ -38,10 +38,4 @@ export default interface AuthProof<R> {
* instance.
*/
revoke(): Promise<void>;
/**
* This method is called when the AuthProof was used in a successful login attempt.
* If you modify the AuthProof, you should make sure changes are persistent.
*/
use?(): Promise<void>;
}

View File

@ -0,0 +1,16 @@
import {Request, Response} from "express";
import Controller from "../Controller";
import Mail from "../Mail";
import NunjucksComponent from "../components/NunjucksComponent";
export default class MailController extends Controller {
public routes(): void {
this.get("/mail/:template", this.getMail, 'mail');
}
protected async getMail(request: Request, response: Response): Promise<void> {
const template = request.params['template'];
response.send(Mail.parse(this.getApp().as(NunjucksComponent).getEnvironment(),
`mails/${template}.mjml.njk`, request.query, false));
}
}

View File

@ -1,13 +0,0 @@
import Migration from "../../db/Migration.js";
export default class AddUsedToMagicLinksMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE magic_links
ADD COLUMN used BOOLEAN NOT NULL`);
await this.query(`DELETE FROM magic_links`);
}
public async rollback(): Promise<void> {
await this.query('ALTER TABLE magic_links DROP COLUMN IF EXISTS used');
}
}

View File

@ -1,6 +0,0 @@
export default {
LOGIN: 'login',
REGISTER: 'register',
ADD_EMAIL: 'add_email',
REMOVE_PASSWORD: 'remove_password',
};

View File

@ -0,0 +1,187 @@
import {NextFunction, Request, Response} from "express";
import Controller from "../../Controller";
import MagicLink from "../models/MagicLink";
import {BadRequestError} from "../../HttpError";
import UserEmail from "../models/UserEmail";
import MagicLinkController from "./MagicLinkController";
import {MailTemplate} from "../../Mail";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
import geoip from "geoip-lite";
import AuthController from "../AuthController";
import RedirectBackComponent from "../../components/RedirectBackComponent";
import {AuthMiddleware} from "../AuthComponent";
import User from "../models/User";
export default abstract class MagicLinkAuthController extends AuthController {
public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise<User | null> {
const session = req.getSession();
if (magicLink.getOrFail('session_id') !== session.id) throw new BadOwnerMagicLink();
if (!await magicLink.isAuthorized()) throw new UnauthorizedMagicLink();
if (!await magicLink.isValid()) throw new InvalidMagicLink();
// Auth
try {
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
session, magicLink, undefined, undefined, async (connection, user) => {
const callbacks: RegisterCallback[] = [];
const userEmail = UserEmail.create({
user_id: user.id,
email: magicLink.getOrFail('email'),
});
await userEmail.save(connection, c => callbacks.push(c));
user.main_email_id = userEmail.id;
await user.save(connection, c => callbacks.push(c));
return callbacks;
});
} catch (e) {
if (e instanceof PendingApprovalAuthError) {
res.format({
json: () => {
res.json({
'status': 'warning',
'message': `Your account is pending review. You'll receive an email once you're approved.`,
});
},
html: () => {
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
res.redirect('/');
},
});
return null;
} else {
throw e;
}
}
}
protected readonly loginMagicLinkActionType: string = 'Login';
protected readonly registerMagicLinkActionType: string = 'Register';
private readonly magicLinkMailTemplate: MailTemplate;
protected constructor(magicLinkMailTemplate: MailTemplate) {
super();
this.magicLinkMailTemplate = magicLinkMailTemplate;
}
protected async getAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const link = await MagicLink.bySessionId(req.getSession().id,
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
if (link && await link.isValid()) {
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}));
return;
}
await super.getAuth(req, res, next);
}
protected async postAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const email = req.body.email;
if (!email) throw new BadRequestError('Email not specified.', 'Please try again.', req.originalUrl);
let userEmail = await UserEmail.select().where('email', email).first();
let isRegistration = false;
if (!userEmail) {
isRegistration = true;
userEmail = UserEmail.create({
email: email,
main: true,
});
await userEmail.validate(true);
}
if (!isRegistration || req.body.confirm_register === 'confirm') {
// Register (email link)
const geo = geoip.lookup(req.ip);
await MagicLinkController.sendMagicLink(
this.getApp(),
req.getSession().id,
isRegistration ? this.registerMagicLinkActionType : this.loginMagicLinkActionType,
Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}),
email,
this.magicLinkMailTemplate,
{
type: isRegistration ? 'register' : 'login',
ip: req.ip,
geo: geo ? `${geo.city}, ${geo.country}` : 'Unknown location',
},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || RedirectBackComponent.getPreviousURL(req),
}));
} else {
// Confirm registration req
req.flash('register_confirm_email', email);
res.redirect(Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}));
}
}
/**
* Check whether a magic link is authorized, and authenticate if yes
*/
protected async getCheckAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const magicLink = await MagicLink.bySessionId(req.getSession().id,
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
if (!magicLink) {
res.format({
json: () => {
throw new BadRequestError(
'No magic link were found linked with that session.',
'Please retry once you have requested a magic link.',
req.originalUrl,
);
},
default: () => {
req.flash('warning', 'No magic link found. Please try again.');
res.redirect(Controller.route('auth'));
},
});
return;
}
const user = await MagicLinkAuthController.checkAndAuth(req, res, magicLink);
if (user) {
// Auth success
const username = user.name;
res.format({
json: () => {
res.json({'status': 'success', 'message': `Welcome, ${username}!`});
},
default: () => {
req.flash('success', `Authentication success. Welcome, ${username}!`);
res.redirect('/');
},
});
}
}
}
export class BadOwnerMagicLink extends AuthError {
public constructor() {
super(`This magic link doesn't belong to this session.`);
}
}
export class UnauthorizedMagicLink extends AuthError {
public constructor() {
super(`This magic link is unauthorized.`);
}
}
export class InvalidMagicLink extends AuthError {
public constructor() {
super(`This magic link is invalid.`);
}
}

Some files were not shown because too many files have changed in this diff Show More